@ammduncan/easel 0.2.8 → 0.2.10
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/CHANGELOG.md +20 -0
- package/dist/client/index.css +6 -8
- package/dist/client/viewer.css +54 -4
- package/dist/client/viewer.html +1 -0
- package/dist/client/viewer.js +164 -20
- package/dist/http-server.js +7 -1
- package/dist/mcp.js +48 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.2.10 — 2026-05-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **Auto-open moved from "first tool call" to "first push", and is now aware of which sessions are currently being viewed.** The previous behaviour fired on the very first tool call (often `label` or `config`) and used a binary "have we attempted ever?" guard, which meant agents calling `label` before any push would burn the attempt without opening, and parent orchestrators that never push would still trigger a tab. New decision tree:
|
|
9
|
+
- `sessionTabs > 0` (this session is being viewed in ≥1 tab) → silent push, no auto-open.
|
|
10
|
+
- `otherTabs > 0` (easel is being viewed but on a different session) → no auto-open; the push response now hints to the agent to ask the user whether to switch to this session via the topbar `switch ▾` dropdown or call `open` for a new window. Avoids surprising users who deliberately switched their existing easel tab to look at another session.
|
|
11
|
+
- both `0` and no hook-managed tab → auto-open one tab.
|
|
12
|
+
- Still one-shot per MCP child lifetime — if the user closes the tab after the first auto-open, subsequent pushes don't re-open; the response hints they can call `open` to force one.
|
|
13
|
+
- Explicit `open` calls also mark the attempt as taken, so an `open` followed by a manual close is respected the same way.
|
|
14
|
+
- Server's `/api/push` response now returns `otherTabs` alongside `sessionTabs`.
|
|
15
|
+
|
|
16
|
+
## 0.2.9 — 2026-05-22
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Per-push download menu with PNG and PDF.** The download icon on each push card now opens a small popover anchored under the icon, offering PNG (existing flow) or PDF. PDF mode reuses the same `html-to-image` rasterise we already run for PNG, then embeds the resulting bitmap into a `jsPDF` document sized to the canvas dimensions — one continuous page, no pagination, regardless of card height. jsPDF loads from the same jsDelivr CDN we already use for `html-to-image`.
|
|
20
|
+
- **Real loading state on the download icon.** Replaces the previous subtle pulse — during render the icon swaps to a spinning ring and the button is click-locked until the export finishes (PNG anchor download or jsPDF save). Errors from the iframe-side rasterise now propagate to the parent via a new `easel:image-error` message and surface as an alert so silent hangs are visible.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Switcher delete button no longer overlaps the push count / timestamp.** The trash icon was absolute-positioned at `right: 36px` and sat on top of `.count` on hover. Moved into the flex flow as a sibling after `.count`, with `visibility: hidden` reserving its 22px slot so the count text stays clear at all times.
|
|
24
|
+
|
|
5
25
|
## 0.2.8 — 2026-05-22
|
|
6
26
|
|
|
7
27
|
### Docs
|
package/dist/client/index.css
CHANGED
|
@@ -76,17 +76,11 @@
|
|
|
76
76
|
opacity: 1 !important;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
.switcher-item {
|
|
80
|
-
position: relative;
|
|
81
|
-
}
|
|
82
79
|
.switcher-del {
|
|
83
|
-
position: absolute;
|
|
84
|
-
top: 50%;
|
|
85
|
-
right: 36px;
|
|
86
|
-
transform: translateY(-50%);
|
|
87
80
|
display: inline-flex;
|
|
88
81
|
align-items: center;
|
|
89
82
|
justify-content: center;
|
|
83
|
+
flex-shrink: 0;
|
|
90
84
|
width: 22px;
|
|
91
85
|
height: 22px;
|
|
92
86
|
background: transparent;
|
|
@@ -94,10 +88,14 @@
|
|
|
94
88
|
border-radius: 6px;
|
|
95
89
|
color: var(--ds-muted);
|
|
96
90
|
cursor: pointer;
|
|
91
|
+
visibility: hidden;
|
|
97
92
|
opacity: 0;
|
|
98
93
|
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
|
|
99
94
|
}
|
|
100
|
-
.switcher-item:hover .switcher-del {
|
|
95
|
+
.switcher-item:hover .switcher-del {
|
|
96
|
+
visibility: visible;
|
|
97
|
+
opacity: 0.65;
|
|
98
|
+
}
|
|
101
99
|
.switcher-del:hover {
|
|
102
100
|
background: var(--ds-surface-soft);
|
|
103
101
|
color: var(--ds-danger);
|
package/dist/client/viewer.css
CHANGED
|
@@ -588,17 +588,67 @@ body {
|
|
|
588
588
|
.push-export[data-loading] {
|
|
589
589
|
opacity: 1 !important;
|
|
590
590
|
color: var(--ds-accent);
|
|
591
|
-
|
|
591
|
+
pointer-events: none;
|
|
592
592
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
593
|
+
.push-export .push-export-spinner {
|
|
594
|
+
display: none;
|
|
595
|
+
}
|
|
596
|
+
.push-export[data-loading] .push-export-icon {
|
|
597
|
+
display: none;
|
|
598
|
+
}
|
|
599
|
+
.push-export[data-loading] .push-export-spinner {
|
|
600
|
+
display: block;
|
|
601
|
+
animation: easel-spin 0.7s linear infinite;
|
|
602
|
+
}
|
|
603
|
+
@keyframes easel-spin {
|
|
604
|
+
to { transform: rotate(360deg); }
|
|
596
605
|
}
|
|
597
606
|
.push-del svg,
|
|
598
607
|
.push-export svg {
|
|
599
608
|
display: block;
|
|
600
609
|
}
|
|
601
610
|
|
|
611
|
+
.push-export-wrap {
|
|
612
|
+
position: relative;
|
|
613
|
+
display: inline-flex;
|
|
614
|
+
}
|
|
615
|
+
.push-export-menu {
|
|
616
|
+
position: absolute;
|
|
617
|
+
top: calc(100% + 4px);
|
|
618
|
+
right: 0;
|
|
619
|
+
z-index: 30;
|
|
620
|
+
display: flex;
|
|
621
|
+
flex-direction: column;
|
|
622
|
+
min-width: 96px;
|
|
623
|
+
padding: 4px;
|
|
624
|
+
background: var(--ds-bg-elev);
|
|
625
|
+
border: 1px solid var(--ds-border, rgba(0, 0, 0, 0.12));
|
|
626
|
+
border-radius: 8px;
|
|
627
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
|
628
|
+
}
|
|
629
|
+
.push-export-menu[hidden] {
|
|
630
|
+
display: none;
|
|
631
|
+
}
|
|
632
|
+
.push-export-menu button {
|
|
633
|
+
appearance: none;
|
|
634
|
+
display: block;
|
|
635
|
+
width: 100%;
|
|
636
|
+
padding: 6px 10px;
|
|
637
|
+
background: transparent;
|
|
638
|
+
border: 0;
|
|
639
|
+
border-radius: 6px;
|
|
640
|
+
font: inherit;
|
|
641
|
+
font-size: 12px;
|
|
642
|
+
text-align: left;
|
|
643
|
+
color: var(--ds-ink);
|
|
644
|
+
cursor: pointer;
|
|
645
|
+
}
|
|
646
|
+
.push-export-menu button:hover,
|
|
647
|
+
.push-export-menu button:focus-visible {
|
|
648
|
+
background: var(--ds-surface-soft);
|
|
649
|
+
outline: none;
|
|
650
|
+
}
|
|
651
|
+
|
|
602
652
|
.push-body {
|
|
603
653
|
background: var(--ds-bg-elev);
|
|
604
654
|
overflow: hidden;
|
package/dist/client/viewer.html
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
|
9
9
|
<link rel="stylesheet" href="/static/viewer.css" />
|
|
10
10
|
<link rel="stylesheet" href="/static/index.css" />
|
|
11
|
+
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js"></script>
|
|
11
12
|
<script>
|
|
12
13
|
// Bootstrap theme before paint to avoid flash.
|
|
13
14
|
(function () {
|
package/dist/client/viewer.js
CHANGED
|
@@ -144,21 +144,48 @@
|
|
|
144
144
|
}
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
|
+
if (data.type === "easel:image-error") {
|
|
148
|
+
console.error("[easel] iframe export error", data);
|
|
149
|
+
const iframeEl = cardsEl.querySelector(
|
|
150
|
+
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
151
|
+
);
|
|
152
|
+
if (iframeEl && iframeEl.closest(".push")) {
|
|
153
|
+
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
154
|
+
if (ex) delete ex.dataset.loading;
|
|
155
|
+
}
|
|
156
|
+
alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
147
159
|
if (data.type === "easel:image-ready") {
|
|
148
|
-
|
|
160
|
+
const format = data.format === "pdf" ? "pdf" : "png";
|
|
161
|
+
const clearLoading = () => {
|
|
162
|
+
const iframeEl = cardsEl.querySelector(
|
|
163
|
+
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
164
|
+
);
|
|
165
|
+
if (iframeEl && iframeEl.closest(".push")) {
|
|
166
|
+
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
167
|
+
if (ex) delete ex.dataset.loading;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (format === "pdf") {
|
|
172
|
+
downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
|
|
173
|
+
.catch((err) => {
|
|
174
|
+
console.error("[easel] pdf export failed", err);
|
|
175
|
+
alert("PDF export failed: " + (err && err.message ? err.message : err));
|
|
176
|
+
})
|
|
177
|
+
.finally(clearLoading);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// PNG — direct anchor download.
|
|
149
182
|
const a = document.createElement("a");
|
|
150
183
|
a.href = data.dataUrl;
|
|
151
184
|
a.download = data.filename || "push.png";
|
|
152
185
|
document.body.appendChild(a);
|
|
153
186
|
a.click();
|
|
154
187
|
a.remove();
|
|
155
|
-
|
|
156
|
-
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
157
|
-
);
|
|
158
|
-
if (btn && btn.closest(".push")) {
|
|
159
|
-
const ex = btn.closest(".push").querySelector(".push-export");
|
|
160
|
-
if (ex) delete ex.dataset.loading;
|
|
161
|
-
}
|
|
188
|
+
clearLoading();
|
|
162
189
|
return;
|
|
163
190
|
}
|
|
164
191
|
});
|
|
@@ -168,6 +195,62 @@
|
|
|
168
195
|
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
169
196
|
}
|
|
170
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Embed a PNG dataURL into a single-page PDF sized to the image's pixel
|
|
200
|
+
* dimensions, producing a continuous (no page-breaks) document, then save.
|
|
201
|
+
*/
|
|
202
|
+
function downloadAsPdf(dataUrl, filename) {
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const jspdfNs = window.jspdf;
|
|
205
|
+
if (!jspdfNs || !jspdfNs.jsPDF) {
|
|
206
|
+
reject(new Error("jsPDF not loaded"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const img = new Image();
|
|
210
|
+
img.onload = () => {
|
|
211
|
+
try {
|
|
212
|
+
const w = img.naturalWidth || img.width;
|
|
213
|
+
const h = img.naturalHeight || img.height;
|
|
214
|
+
const pdf = new jspdfNs.jsPDF({
|
|
215
|
+
unit: "px",
|
|
216
|
+
format: [w, h],
|
|
217
|
+
orientation: w > h ? "landscape" : "portrait",
|
|
218
|
+
hotfixes: ["px_scaling"],
|
|
219
|
+
});
|
|
220
|
+
pdf.addImage(dataUrl, "PNG", 0, 0, w, h);
|
|
221
|
+
pdf.save(filename);
|
|
222
|
+
resolve();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
reject(err);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
img.onerror = () => reject(new Error("image decode failed"));
|
|
228
|
+
img.src = dataUrl;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Dismiss any open push-export menu on outside click / Escape.
|
|
233
|
+
document.addEventListener("click", (e) => {
|
|
234
|
+
if (e.target.closest && e.target.closest(".push-export-wrap")) return;
|
|
235
|
+
document.querySelectorAll(".push-export-menu").forEach((m) => {
|
|
236
|
+
if (!m.hidden) {
|
|
237
|
+
m.hidden = true;
|
|
238
|
+
const sib = m.previousElementSibling;
|
|
239
|
+
if (sib) sib.setAttribute("aria-expanded", "false");
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
document.addEventListener("keydown", (e) => {
|
|
244
|
+
if (e.key !== "Escape") return;
|
|
245
|
+
document.querySelectorAll(".push-export-menu").forEach((m) => {
|
|
246
|
+
if (!m.hidden) {
|
|
247
|
+
m.hidden = true;
|
|
248
|
+
const sib = m.previousElementSibling;
|
|
249
|
+
if (sib) sib.setAttribute("aria-expanded", "false");
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
171
254
|
/* ============================================================
|
|
172
255
|
Theming
|
|
173
256
|
============================================================ */
|
|
@@ -363,21 +446,75 @@
|
|
|
363
446
|
time.textContent = formatTime(push.createdAt);
|
|
364
447
|
meta.appendChild(time);
|
|
365
448
|
|
|
449
|
+
const exportWrap = document.createElement("div");
|
|
450
|
+
exportWrap.className = "push-export-wrap";
|
|
451
|
+
|
|
366
452
|
const exportBtn = document.createElement("button");
|
|
367
453
|
exportBtn.className = "push-export";
|
|
368
454
|
exportBtn.type = "button";
|
|
369
|
-
exportBtn.title = "
|
|
370
|
-
exportBtn.setAttribute("aria-label", "
|
|
455
|
+
exportBtn.title = "Download";
|
|
456
|
+
exportBtn.setAttribute("aria-label", "Download this push");
|
|
457
|
+
exportBtn.setAttribute("aria-haspopup", "menu");
|
|
458
|
+
exportBtn.setAttribute("aria-expanded", "false");
|
|
371
459
|
exportBtn.innerHTML =
|
|
372
|
-
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>'
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
460
|
+
'<svg class="push-export-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>' +
|
|
461
|
+
'<svg class="push-export-spinner" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/></svg>';
|
|
462
|
+
|
|
463
|
+
const exportMenu = document.createElement("div");
|
|
464
|
+
exportMenu.className = "push-export-menu";
|
|
465
|
+
exportMenu.setAttribute("role", "menu");
|
|
466
|
+
exportMenu.hidden = true;
|
|
467
|
+
exportMenu.innerHTML =
|
|
468
|
+
'<button type="button" role="menuitem" data-format="png">PNG</button>' +
|
|
469
|
+
'<button type="button" role="menuitem" data-format="pdf">PDF</button>';
|
|
470
|
+
|
|
471
|
+
const safeTitle = () =>
|
|
472
|
+
(push.title || "push-" + push.index)
|
|
377
473
|
.toLowerCase()
|
|
378
474
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
379
475
|
.replace(/^-+|-+$/g, "")
|
|
380
476
|
.slice(0, 60) || "push";
|
|
477
|
+
|
|
478
|
+
function closeExportMenu() {
|
|
479
|
+
if (exportMenu.hidden) return;
|
|
480
|
+
exportMenu.hidden = true;
|
|
481
|
+
exportBtn.setAttribute("aria-expanded", "false");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function openExportMenu() {
|
|
485
|
+
// Close any other open export menus first.
|
|
486
|
+
cardsEl.querySelectorAll(".push-export-menu").forEach((m) => {
|
|
487
|
+
if (m !== exportMenu) {
|
|
488
|
+
m.hidden = true;
|
|
489
|
+
const sib = m.previousElementSibling;
|
|
490
|
+
if (sib) sib.setAttribute("aria-expanded", "false");
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
exportMenu.hidden = false;
|
|
494
|
+
exportBtn.setAttribute("aria-expanded", "true");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
exportBtn.addEventListener("click", (e) => {
|
|
498
|
+
e.stopPropagation();
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
if (exportMenu.hidden) {
|
|
501
|
+
openExportMenu();
|
|
502
|
+
} else {
|
|
503
|
+
closeExportMenu();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
exportMenu.addEventListener("click", (e) => {
|
|
508
|
+
const btn = e.target.closest("button[data-format]");
|
|
509
|
+
if (!btn) return;
|
|
510
|
+
e.stopPropagation();
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
const format = btn.dataset.format === "pdf" ? "pdf" : "png";
|
|
513
|
+
closeExportMenu();
|
|
514
|
+
requestExport(format);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
function requestExport(format) {
|
|
381
518
|
exportBtn.dataset.loading = "true";
|
|
382
519
|
|
|
383
520
|
// Match the export bg to what the user sees inside this card:
|
|
@@ -387,6 +524,7 @@
|
|
|
387
524
|
const isFlat = currentDensity() === "flat";
|
|
388
525
|
const bgVar = isFlat ? "--ds-bg" : "--ds-bg-elev";
|
|
389
526
|
const bgColor = rootStyle.getPropertyValue(bgVar).trim() || "#ffffff";
|
|
527
|
+
const filename = safeTitle() + (format === "pdf" ? ".pdf" : ".png");
|
|
390
528
|
|
|
391
529
|
try {
|
|
392
530
|
iframe.contentWindow &&
|
|
@@ -394,7 +532,8 @@
|
|
|
394
532
|
{
|
|
395
533
|
type: "easel:image",
|
|
396
534
|
pushId: push.id,
|
|
397
|
-
filename
|
|
535
|
+
filename,
|
|
536
|
+
format,
|
|
398
537
|
bgColor,
|
|
399
538
|
},
|
|
400
539
|
"*",
|
|
@@ -403,8 +542,11 @@
|
|
|
403
542
|
delete exportBtn.dataset.loading;
|
|
404
543
|
console.error("[easel] export failed", err);
|
|
405
544
|
}
|
|
406
|
-
}
|
|
407
|
-
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
exportWrap.appendChild(exportBtn);
|
|
548
|
+
exportWrap.appendChild(exportMenu);
|
|
549
|
+
meta.appendChild(exportWrap);
|
|
408
550
|
|
|
409
551
|
const del = document.createElement("button");
|
|
410
552
|
del.className = "push-del";
|
|
@@ -676,6 +818,7 @@ ${body}
|
|
|
676
818
|
if (e.data.type === "easel:image") {
|
|
677
819
|
var pushId = e.data.pushId;
|
|
678
820
|
var filename = e.data.filename || "push.png";
|
|
821
|
+
var format = e.data.format === "pdf" ? "pdf" : "png";
|
|
679
822
|
var bgColor =
|
|
680
823
|
e.data.bgColor ||
|
|
681
824
|
getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
|
|
@@ -701,9 +844,10 @@ ${body}
|
|
|
701
844
|
width: width,
|
|
702
845
|
height: height,
|
|
703
846
|
}).then(function(dataUrl){
|
|
704
|
-
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename }, "*");
|
|
847
|
+
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
|
|
705
848
|
}).catch(function(err){
|
|
706
849
|
console.error("[easel] export failed", err);
|
|
850
|
+
parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
|
|
707
851
|
});
|
|
708
852
|
}
|
|
709
853
|
if (document.fonts && document.fonts.ready) {
|
|
@@ -723,7 +867,7 @@ ${body}
|
|
|
723
867
|
const configScript =
|
|
724
868
|
"<script src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js'></script><script>(function(){function a(c){if(!c)return;if(c.theme==='light'||c.theme==='dark'){document.documentElement.setAttribute('data-theme',c.theme);window.__claudeDisplayTheme=c.theme}if(c.preset==='paper'||c.preset==='aurora'||c.preset==='slate'){document.documentElement.setAttribute('data-preset',c.preset);window.__claudeDisplayPreset=c.preset}if(c.density==='carded'||c.density==='flat'){document.documentElement.setAttribute('data-density',c.density);window.__claudeDisplayDensity=c.density}}a(" +
|
|
725
869
|
JSON.stringify({ theme, preset, density }) +
|
|
726
|
-
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}if(e.data.type==='easel:image'){var pid=e.data.pushId;var fn=e.data.filename||'push.png';var bg=e.data.bgColor||'#ffffff';if(!window.htmlToImage)return;window.htmlToImage.toPng(document.body,{backgroundColor:bg,pixelRatio:4,cacheBust:true}).then(function(u){parent.postMessage({type:'easel:image-ready',pushId:pid,dataUrl:u,filename:fn},'*')}).catch(function(err){console.error(err)})}})})();</script>";
|
|
870
|
+
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}if(e.data.type==='easel:image'){var pid=e.data.pushId;var fn=e.data.filename||'push.png';var fmt=e.data.format==='pdf'?'pdf':'png';var bg=e.data.bgColor||'#ffffff';if(!window.htmlToImage)return;window.htmlToImage.toPng(document.body,{backgroundColor:bg,pixelRatio:4,cacheBust:true}).then(function(u){parent.postMessage({type:'easel:image-ready',pushId:pid,dataUrl:u,filename:fn,format:fmt},'*')}).catch(function(err){console.error(err);parent.postMessage({type:'easel:image-error',pushId:pid,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')})}})})();</script>";
|
|
727
871
|
const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
|
|
728
872
|
const combined = configScript + measureScript;
|
|
729
873
|
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
|
package/dist/http-server.js
CHANGED
|
@@ -164,15 +164,21 @@ export function startHttpServer() {
|
|
|
164
164
|
sweepIdleSessions();
|
|
165
165
|
}
|
|
166
166
|
let sessionTabs = 0;
|
|
167
|
+
let otherTabs = 0;
|
|
167
168
|
for (const c of clients.values()) {
|
|
168
|
-
if (c.sessionId === sessionId)
|
|
169
|
+
if (c.sessionId === sessionId) {
|
|
169
170
|
sessionTabs++;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
otherTabs++;
|
|
174
|
+
}
|
|
170
175
|
}
|
|
171
176
|
res.json({
|
|
172
177
|
url: `http://localhost:${port}/s/${sessionId}`,
|
|
173
178
|
slide_id: push.id,
|
|
174
179
|
index: push.index,
|
|
175
180
|
sessionTabs,
|
|
181
|
+
otherTabs,
|
|
176
182
|
});
|
|
177
183
|
});
|
|
178
184
|
const server = app.listen(port, "127.0.0.1", () => {
|
package/dist/mcp.js
CHANGED
|
@@ -33,15 +33,41 @@ function hookHasFiredForThisPpid() {
|
|
|
33
33
|
return existsSync(join(HOOK_DIR, `cc-session-${process.ppid}.txt`));
|
|
34
34
|
}
|
|
35
35
|
// One-shot guard: only auto-open once per MCP-child lifetime. If the user
|
|
36
|
-
// closes the tab afterwards, subsequent pushes won't re-open it
|
|
36
|
+
// closes the tab afterwards, subsequent pushes won't re-open it — the user
|
|
37
|
+
// closing the tab is treated as an explicit dismissal we should respect.
|
|
37
38
|
let autoOpenAttempted = false;
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Decide whether to auto-open the session URL on first push.
|
|
41
|
+
*
|
|
42
|
+
* - `sessionTabs > 0`: a tab is already showing THIS session → no-op.
|
|
43
|
+
* - `otherTabs > 0`: easel is open, but on a different session. Don't surprise
|
|
44
|
+
* the user with another window — return "other-session" so the caller can
|
|
45
|
+
* tell the agent to ask whether to use the topbar switcher or open a new tab.
|
|
46
|
+
* - both 0 + no hook-managed tab: auto-open one tab.
|
|
47
|
+
*
|
|
48
|
+
* One-shot per MCP child lifetime. Closing the tab counts as dismissal;
|
|
49
|
+
* subsequent pushes won't re-open.
|
|
50
|
+
*/
|
|
51
|
+
function autoOpenIfNeeded(url, sessionTabs, otherTabs) {
|
|
39
52
|
if (autoOpenAttempted)
|
|
40
|
-
return;
|
|
53
|
+
return { kind: "noop" };
|
|
54
|
+
if (sessionTabs > 0) {
|
|
55
|
+
autoOpenAttempted = true;
|
|
56
|
+
return { kind: "noop" };
|
|
57
|
+
}
|
|
58
|
+
if (otherTabs > 0) {
|
|
59
|
+
// Easel is alive in another session — don't open a new window without asking.
|
|
60
|
+
autoOpenAttempted = true;
|
|
61
|
+
return { kind: "other-session" };
|
|
62
|
+
}
|
|
63
|
+
if (hookHasFiredForThisPpid()) {
|
|
64
|
+
// Claude Code's SessionStart hook handled it (or tried to).
|
|
65
|
+
autoOpenAttempted = true;
|
|
66
|
+
return { kind: "noop" };
|
|
67
|
+
}
|
|
41
68
|
autoOpenAttempted = true;
|
|
42
|
-
if (hookHasFiredForThisPpid())
|
|
43
|
-
return; // Claude Code already opened it
|
|
44
69
|
openUrlInBrowser(url);
|
|
70
|
+
return { kind: "opened" };
|
|
45
71
|
}
|
|
46
72
|
const inputSchema = {
|
|
47
73
|
type: "object",
|
|
@@ -191,12 +217,12 @@ export async function main() {
|
|
|
191
217
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
192
218
|
const sessionId = resolveClaudeSessionId();
|
|
193
219
|
const { port } = await ensureHttpServer();
|
|
194
|
-
// Non-Claude-Code clients have no SessionStart hook to open the tab —
|
|
195
|
-
// we do it ourselves on the first tool call instead.
|
|
196
|
-
maybeAutoOpenTab(`http://localhost:${port}/s/${sessionId}`);
|
|
197
220
|
if (req.params.name === TOOL_OPEN) {
|
|
198
221
|
const url = `http://localhost:${port}/s/${sessionId}`;
|
|
199
222
|
openUrlInBrowser(url);
|
|
223
|
+
// Explicit open also counts as "we've opened it" — if the user closes
|
|
224
|
+
// this tab later, a subsequent push shouldn't auto-reopen.
|
|
225
|
+
autoOpenAttempted = true;
|
|
200
226
|
return {
|
|
201
227
|
content: [
|
|
202
228
|
{
|
|
@@ -263,9 +289,20 @@ export async function main() {
|
|
|
263
289
|
kind: args.kind,
|
|
264
290
|
port,
|
|
265
291
|
});
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
292
|
+
const url = `http://localhost:${port}/s/${sessionId}`;
|
|
293
|
+
const openResult = autoOpenIfNeeded(url, result.sessionTabs, result.otherTabs);
|
|
294
|
+
let tabHint = "";
|
|
295
|
+
if (openResult.kind === "opened") {
|
|
296
|
+
tabHint = " · opened a tab for this session";
|
|
297
|
+
}
|
|
298
|
+
else if (openResult.kind === "other-session") {
|
|
299
|
+
tabHint =
|
|
300
|
+
" · easel is open in another tab on a different session — ASK the user whether to switch via the topbar 'switch ▾' dropdown to this session, or call `open` to launch a new tab/window for it";
|
|
301
|
+
}
|
|
302
|
+
else if (result.sessionTabs === 0) {
|
|
303
|
+
tabHint =
|
|
304
|
+
" · no tab open for this session — user previously closed it; call `open` to force a new one if needed";
|
|
305
|
+
}
|
|
269
306
|
return {
|
|
270
307
|
content: [
|
|
271
308
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|