pindeck 3.4.0 → 4.0.0-beta.1
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 +121 -0
- package/README.md +33 -736
- package/cli.mjs +8 -1
- package/docs/configuration.md +51 -0
- package/docs/how-it-works.md +56 -0
- package/docs/linux-server.md +36 -0
- package/docs/preparing-decks.md +88 -0
- package/docs/using.md +45 -0
- package/lib/agent-tools.mjs +2 -2
- package/lib/deck-list.mjs +5 -3
- package/lib/deck-target.mjs +9 -5
- package/lib/eids.mjs +7 -3
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +9 -0
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +19 -9
- package/public/editor.css +34 -3
- package/public/editor.js +205 -34
- package/public/index.html +21 -2
- package/server.mjs +240 -17
package/public/editor.js
CHANGED
|
@@ -122,9 +122,9 @@ const editorHost = {
|
|
|
122
122
|
state.current = Math.min(state.current, state.slides.length - 1);
|
|
123
123
|
if (inf.current !== state.current) bridge.goto(state.current);
|
|
124
124
|
bridge.setMode(state.mode);
|
|
125
|
-
bridge
|
|
125
|
+
applyEditable(bridge);
|
|
126
126
|
refreshPinsFromSlides();
|
|
127
|
-
bridge.renderPins(
|
|
127
|
+
bridge.renderPins(allPins());
|
|
128
128
|
if (state.sel) bridge.selectEid(state.sel.eid);
|
|
129
129
|
if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
|
|
130
130
|
renderSlides();
|
|
@@ -144,6 +144,7 @@ const editorHost = {
|
|
|
144
144
|
state.pins.push({ ...pin, n });
|
|
145
145
|
pinsChanged();
|
|
146
146
|
},
|
|
147
|
+
openComment(id) { openComment(id); },
|
|
147
148
|
removePin(n) {
|
|
148
149
|
state.pins = state.pins.filter((p) => p.n !== n);
|
|
149
150
|
pinsChanged();
|
|
@@ -396,7 +397,7 @@ function notesHtml() {
|
|
|
396
397
|
function showNotes(eid, d) {
|
|
397
398
|
const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
|
|
398
399
|
notes = { eid, text: "", html: rich == null };
|
|
399
|
-
notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
|
|
400
|
+
notesBox.contentEditable = !d || state.status?.source ? "false" : notes.html ? "plaintext-only" : "true";
|
|
400
401
|
if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
|
|
401
402
|
notes.text = notesValue();
|
|
402
403
|
}
|
|
@@ -481,31 +482,37 @@ function setNotesHeight(h, save = true) {
|
|
|
481
482
|
$("notes-split").setAttribute("aria-valuenow", String(h));
|
|
482
483
|
if (save) try { localStorage.setItem("editor.notesHeight", String(h)); } catch {}
|
|
483
484
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
485
|
+
// drag a splitter (or ↑ ↓ on it) to resize the box below it; double-click for the default size
|
|
486
|
+
function splitter(split, getH, setH, defH) {
|
|
487
|
+
split.addEventListener("pointerdown", (e) => {
|
|
488
|
+
if (e.button !== 0) return;
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
const y0 = e.clientY, h0 = getH();
|
|
491
|
+
split.setPointerCapture(e.pointerId);
|
|
492
|
+
document.body.classList.add("resizing-notes");
|
|
493
|
+
split.classList.add("dragging");
|
|
494
|
+
const move = (ev) => setH(h0 - (ev.clientY - y0));
|
|
495
|
+
const up = () => {
|
|
496
|
+
document.body.classList.remove("resizing-notes");
|
|
497
|
+
split.classList.remove("dragging");
|
|
498
|
+
split.removeEventListener("pointermove", move);
|
|
499
|
+
split.removeEventListener("pointerup", up);
|
|
500
|
+
split.removeEventListener("pointercancel", up);
|
|
501
|
+
};
|
|
502
|
+
split.addEventListener("pointermove", move);
|
|
503
|
+
split.addEventListener("pointerup", up);
|
|
504
|
+
split.addEventListener("pointercancel", up);
|
|
505
|
+
});
|
|
506
|
+
split.addEventListener("dblclick", () => setH(defH));
|
|
507
|
+
split.addEventListener("keydown", (e) => {
|
|
508
|
+
const step = e.shiftKey ? 64 : 16;
|
|
509
|
+
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
e.stopPropagation(); // not a slide change
|
|
512
|
+
setH(getH() + (e.key === "ArrowUp" ? step : -step));
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
splitter($("notes-split"), () => notesBox.offsetHeight, setNotesHeight, NOTES_H);
|
|
509
516
|
setNotesHeight((() => { try { return +localStorage.getItem("editor.notesHeight") || NOTES_H; } catch { return NOTES_H; } })(), false);
|
|
510
517
|
setNotesOpen((() => { try { return localStorage.getItem("editor.notes") !== "closed"; } catch { return true; } })());
|
|
511
518
|
|
|
@@ -640,7 +647,7 @@ function refreshPinsFromSlides() {
|
|
|
640
647
|
});
|
|
641
648
|
}
|
|
642
649
|
function pinsChanged() {
|
|
643
|
-
main?.bridge.renderPins(
|
|
650
|
+
main?.bridge.renderPins(allPins());
|
|
644
651
|
renderPinTray();
|
|
645
652
|
renderSlides();
|
|
646
653
|
}
|
|
@@ -682,6 +689,146 @@ $("pin-tray").addEventListener("mouseover", (e) => {
|
|
|
682
689
|
});
|
|
683
690
|
$("clear-pins").onclick = () => { state.pins = []; pinsChanged(); };
|
|
684
691
|
|
|
692
|
+
/* ---- Review comments (lib/review.mjs): notes on the slides, kept for later ---------------------------
|
|
693
|
+
Shown in the panel under the chat header and as blue markers on the slides (grey: the element or slide
|
|
694
|
+
they were made on is no longer there as it was). They never go into the deck. */
|
|
695
|
+
const MATCHED = new Set(["ok", "moved"]);
|
|
696
|
+
const FLAG = {
|
|
697
|
+
changed: "Unmatched: the element's text has changed since (already addressed?)",
|
|
698
|
+
missing: "Unmatched: the element is no longer on this slide",
|
|
699
|
+
"no-slide": "Unmatched: the slide is no longer in the deck",
|
|
700
|
+
};
|
|
701
|
+
state.comments = [];
|
|
702
|
+
let showResolved = false;
|
|
703
|
+
let editing = null; // { id, text, reply }: the comment being edited or replied to, kept across re-renders (comments refresh while you type)
|
|
704
|
+
const WHO = { you: "You", assistant: "Assistant", "command line": "Command line" };
|
|
705
|
+
try { showResolved = localStorage.getItem("editor.comments.resolved") === "1"; } catch {}
|
|
706
|
+
$("comments-resolved").checked = showResolved;
|
|
707
|
+
const commentWhere = (c) => `S${c.loc.index != null ? c.loc.index + 1 : "?"} · ${c.target.eid ? c.target.tag.split(".")[0] : "slide"}`;
|
|
708
|
+
function commentPins() {
|
|
709
|
+
return state.comments.filter((c) => c.status !== "resolved" && c.loc.slideEid).map((c) => ({
|
|
710
|
+
comment: c.id, n: "C" + c.n, stale: !MATCHED.has(c.loc.status),
|
|
711
|
+
slide: { eid: c.loc.slideEid, index: c.loc.index }, eid: c.loc.eid || c.loc.slideEid,
|
|
712
|
+
point: c.point || { x: 30, y: 30 }, at: c.at || { fx: 0, fy: 0 },
|
|
713
|
+
title: `C${c.n}: ${c.text.slice(0, 160)}${FLAG[c.loc.status] ? "\n" + FLAG[c.loc.status] : ""}`,
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
const allPins = () => [...state.pins, ...commentPins()];
|
|
717
|
+
function applyComments(v) {
|
|
718
|
+
state.comments = v.comments || [];
|
|
719
|
+
const open = state.comments.filter((c) => c.status !== "resolved").length;
|
|
720
|
+
$("comments-count").textContent = open || "";
|
|
721
|
+
$("comments-btn").title = v.problem || "Review comments: notes on the slides, kept for later";
|
|
722
|
+
$("comments-file").textContent = v.file ? `Saved in ${v.file}; the open ones, for an agent outside PinDeck, in ${v.packet}.` : v.problem || "";
|
|
723
|
+
renderComments();
|
|
724
|
+
main?.bridge.renderPins(allPins());
|
|
725
|
+
}
|
|
726
|
+
function renderComments() {
|
|
727
|
+
const list = state.comments.filter((c) => showResolved || c.status !== "resolved");
|
|
728
|
+
$("comments-empty").hidden = list.length > 0;
|
|
729
|
+
if (state.comments.length) $("comments-empty").textContent = "No open comments. Tick “Show resolved” to see the others.";
|
|
730
|
+
$("comments-send").disabled = !state.comments.some((c) => c.status !== "resolved");
|
|
731
|
+
$("comment-list").innerHTML = list.map((c) => `
|
|
732
|
+
<li class="cmt ${MATCHED.has(c.loc.status) ? "" : "stale"} ${c.status === "resolved" ? "resolved" : ""}" data-id="${esc(c.id)}">
|
|
733
|
+
<div class="top"><span class="n">C${c.n}</span> ${esc(commentWhere(c))}${c.slide.title ? ` “${esc(c.slide.title.slice(0, 40))}”` : ""}
|
|
734
|
+
<span class="spacer"></span>
|
|
735
|
+
<button class="small" data-act="reply" aria-label="Reply to comment C${c.n}">Reply</button>
|
|
736
|
+
<button class="small" data-act="edit" aria-label="Edit comment C${c.n}">Edit</button>
|
|
737
|
+
<button class="small" data-act="${c.status === "resolved" ? "open" : "resolved"}">${c.status === "resolved" ? "Reopen" : "Resolve"}</button>
|
|
738
|
+
<button class="small" data-act="delete" aria-label="Delete comment C${c.n}">×</button></div>
|
|
739
|
+
${editing?.id === c.id && !editing.reply ? editBox(c, "Save", `Text of comment C${c.n}`) : `<p class="text">${esc(c.text)}</p>`}
|
|
740
|
+
${(c.replies || []).map((r) => `<p class="reply ${r.by === "you" ? "mine" : ""}"><b>${esc(WHO[r.by] || r.by || "Agent")}:</b> ${esc(r.text)}</p>`).join("")}
|
|
741
|
+
${editing?.id === c.id && editing.reply ? editBox(c, "Reply", `Reply to comment C${c.n}`) : ""}
|
|
742
|
+
${c.status === "resolved" && c.resolvedBy && c.resolvedBy !== "you" ? `<p class="done">✓ Resolved by the ${esc(c.resolvedBy)}${c.resolution ? `: ${esc(c.resolution)}` : ""}</p>` : ""}
|
|
743
|
+
${FLAG[c.loc.status] && c.status !== "resolved" ? `<p class="flag">${esc(FLAG[c.loc.status])}</p>` : ""}
|
|
744
|
+
</li>`).join("");
|
|
745
|
+
const box = editing && $("comment-list").querySelector("textarea.edit");
|
|
746
|
+
if (box) { box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
|
|
747
|
+
else editing = null; // the comment is gone, or hidden as resolved
|
|
748
|
+
}
|
|
749
|
+
async function saveEdit() {
|
|
750
|
+
const { id, reply } = editing, text = editing.text.trim();
|
|
751
|
+
if (!text) return toast(reply ? "Write the reply first." : "A comment cannot be empty: delete it with × instead.");
|
|
752
|
+
editing = null;
|
|
753
|
+
if (!(await commentApi("comment-update", reply ? { id, reply: text } : { id, text }))) { editing = { id, text, reply }; renderComments(); }
|
|
754
|
+
}
|
|
755
|
+
const editBox = (c, save, label) => `<textarea class="edit" rows="3" aria-label="${label}">${esc(editing.text)}</textarea>
|
|
756
|
+
<div class="edit-actions"><button class="small" data-act="save">${save}</button> <button class="small" data-act="cancel">Cancel</button>
|
|
757
|
+
<span class="muted">⌘↩ ${save === "Save" ? "saves" : "sends"}, Esc cancels</span></div>`;
|
|
758
|
+
function toggleComments(show = $("comments-panel").hidden) {
|
|
759
|
+
$("comments-panel").hidden = !show;
|
|
760
|
+
$("comments-btn").setAttribute("aria-expanded", String(show));
|
|
761
|
+
}
|
|
762
|
+
function openComment(id) {
|
|
763
|
+
const c = state.comments.find((x) => x.id === id);
|
|
764
|
+
if (!c) return;
|
|
765
|
+
if (c.status === "resolved" && !showResolved) { showResolved = true; $("comments-resolved").checked = true; renderComments(); }
|
|
766
|
+
toggleComments(true);
|
|
767
|
+
const li = $("comment-list").querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
768
|
+
li?.scrollIntoView({ block: "nearest" });
|
|
769
|
+
li?.classList.add("flash");
|
|
770
|
+
setTimeout(() => li?.classList.remove("flash"), 1200);
|
|
771
|
+
}
|
|
772
|
+
async function commentApi(p, b) {
|
|
773
|
+
const res = await api(p, b);
|
|
774
|
+
const d = await res.json().catch(() => ({}));
|
|
775
|
+
if (!res.ok) { toast(d.error || `Could not save the comment (${res.status}).`); return null; }
|
|
776
|
+
applyComments(d);
|
|
777
|
+
return d;
|
|
778
|
+
}
|
|
779
|
+
async function addComment() {
|
|
780
|
+
const text = $("input").value.trim();
|
|
781
|
+
if (!text) return toast("Write the comment in the box first, then press Comment.");
|
|
782
|
+
if (state.pins.length > 1) return toast("A comment goes on one pin (or none, for the whole slide): remove the others first.");
|
|
783
|
+
const s = state.slides[state.current];
|
|
784
|
+
const d = await commentApi("comment", { text, pin: state.pins[0] || null, slide: s ? { index: state.current, eid: s.eid } : null });
|
|
785
|
+
if (!d) return;
|
|
786
|
+
$("input").value = "";
|
|
787
|
+
autosize();
|
|
788
|
+
state.pins = [];
|
|
789
|
+
pinsChanged();
|
|
790
|
+
openComment(d.id); // the panel opens on the new comment, so it is seen where it was kept
|
|
791
|
+
}
|
|
792
|
+
$("comment").onclick = addComment;
|
|
793
|
+
$("comments-btn").onclick = () => toggleComments();
|
|
794
|
+
$("comments-resolved").onchange = (e) => {
|
|
795
|
+
showResolved = e.target.checked;
|
|
796
|
+
try { localStorage.setItem("editor.comments.resolved", showResolved ? "1" : "0"); } catch {}
|
|
797
|
+
renderComments();
|
|
798
|
+
};
|
|
799
|
+
$("comment-list").addEventListener("click", (e) => {
|
|
800
|
+
const li = e.target.closest(".cmt");
|
|
801
|
+
const c = li && state.comments.find((x) => x.id === li.dataset.id);
|
|
802
|
+
if (!c || e.target.closest("textarea")) return;
|
|
803
|
+
const act = e.target.closest("[data-act]")?.dataset.act;
|
|
804
|
+
if (act === "edit") { editing = { id: c.id, text: c.text }; return renderComments(); }
|
|
805
|
+
if (act === "reply") { editing = { id: c.id, text: "", reply: true }; return renderComments(); }
|
|
806
|
+
if (act === "cancel") { editing = null; return renderComments(); }
|
|
807
|
+
if (act === "save") return saveEdit();
|
|
808
|
+
if (act === "delete") { if (confirm(`Delete comment C${c.n}?`)) commentApi("comment-delete", { id: c.id }); return; }
|
|
809
|
+
if (act) return commentApi("comment-update", { id: c.id, status: act });
|
|
810
|
+
if (c.loc.index == null) return;
|
|
811
|
+
if (c.loc.index !== state.current) goto(c.loc.index);
|
|
812
|
+
if (c.loc.eid) setTimeout(() => main?.bridge.highlight(c.loc.eid), 80);
|
|
813
|
+
});
|
|
814
|
+
$("comment-list").addEventListener("input", (e) => { if (editing && e.target.matches("textarea.edit")) editing.text = e.target.value; });
|
|
815
|
+
$("comment-list").addEventListener("keydown", (e) => {
|
|
816
|
+
if (!editing || !e.target.matches("textarea.edit")) return;
|
|
817
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); editing = null; renderComments(); }
|
|
818
|
+
else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
|
|
819
|
+
});
|
|
820
|
+
$("comments-send").onclick = async () => {
|
|
821
|
+
const res = await api("comments-request");
|
|
822
|
+
const d = await res.json().catch(() => ({}));
|
|
823
|
+
if (!res.ok) return toast(d.error || "Could not read the comments.");
|
|
824
|
+
if (!d.text) return toast("There are no open comments.");
|
|
825
|
+
const now = $("input").value.trim();
|
|
826
|
+
$("input").value = now ? `${now}\n\n${d.text}` : d.text;
|
|
827
|
+
autosize();
|
|
828
|
+
$("input").focus();
|
|
829
|
+
toast("The open comments are in the message box: read them over, then Send. Resolve each one once it is done.", { ms: 6000 });
|
|
830
|
+
};
|
|
831
|
+
|
|
685
832
|
/* ---- Chat ----------------------------------------------------------------------------------------- */
|
|
686
833
|
const chatEl = $("chat");
|
|
687
834
|
const entryEls = new Map();
|
|
@@ -805,11 +952,17 @@ $("shot-close").onclick = () => $("shot-dialog").close();
|
|
|
805
952
|
// Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
|
|
806
953
|
$("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
|
|
807
954
|
|
|
955
|
+
/* A Quarto deck is rendered from its source: no hand edits, ever (the next render would undo them) */
|
|
956
|
+
const quartoWhy = () => state.status?.source ? `This deck is rendered by Quarto from ${state.status.source}: ask the assistant for changes, or edit ${state.status.source}.` : null;
|
|
957
|
+
function applyEditable(bridge = main?.bridge) {
|
|
958
|
+
bridge?.setEditable(!state.busy && !quartoWhy(), quartoWhy());
|
|
959
|
+
document.body.classList.toggle("quarto", !!quartoWhy());
|
|
960
|
+
}
|
|
808
961
|
function setBusy(busy) {
|
|
809
962
|
state.busy = busy;
|
|
810
963
|
$("notes").readOnly = busy; // hand edits wait until the assistant has finished
|
|
811
|
-
$("working").hidden = !busy;
|
|
812
|
-
|
|
964
|
+
$("working").hidden = !busy && !state.status?.rendering;
|
|
965
|
+
applyEditable();
|
|
813
966
|
$("send").hidden = busy;
|
|
814
967
|
$("stop").hidden = !busy;
|
|
815
968
|
}
|
|
@@ -860,11 +1013,23 @@ $("new-chat").onclick = async () => {
|
|
|
860
1013
|
if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
|
|
861
1014
|
await api("new-chat", {});
|
|
862
1015
|
};
|
|
1016
|
+
// the message box grows with its text from inputMin (set by dragging its top edge) up to 220 px or inputMin
|
|
1017
|
+
const INPUT_H = 96, INPUT_MIN = 48;
|
|
1018
|
+
let inputMin = (() => { try { return +localStorage.getItem("editor.inputHeight") || INPUT_H; } catch { return INPUT_H; } })();
|
|
863
1019
|
function autosize() {
|
|
864
1020
|
const t = $("input");
|
|
865
1021
|
t.style.height = "auto";
|
|
866
|
-
t.style.height = Math.min(220, Math.max(
|
|
1022
|
+
t.style.height = Math.min(Math.max(220, inputMin), Math.max(inputMin, t.scrollHeight + 2)) + "px";
|
|
1023
|
+
}
|
|
1024
|
+
function setInputHeight(h) {
|
|
1025
|
+
const max = Math.max(INPUT_MIN, document.querySelector(".chat-pane").clientHeight * 0.6);
|
|
1026
|
+
inputMin = Math.round(Math.min(max, Math.max(INPUT_MIN, h)));
|
|
1027
|
+
$("input-split").setAttribute("aria-valuenow", String(inputMin));
|
|
1028
|
+
try { localStorage.setItem("editor.inputHeight", String(inputMin)); } catch {}
|
|
1029
|
+
autosize();
|
|
867
1030
|
}
|
|
1031
|
+
splitter($("input-split"), () => $("input").offsetHeight, setInputHeight, INPUT_H);
|
|
1032
|
+
autosize();
|
|
868
1033
|
$("input").addEventListener("input", autosize);
|
|
869
1034
|
$("input").addEventListener("keydown", (e) => {
|
|
870
1035
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
|
|
@@ -916,7 +1081,7 @@ function applyStatus(s) {
|
|
|
916
1081
|
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
917
1082
|
if (s.deck) {
|
|
918
1083
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
919
|
-
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
1084
|
+
const name = s.source || (s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder);
|
|
920
1085
|
$("deck-name").textContent = name;
|
|
921
1086
|
$("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
|
|
922
1087
|
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
@@ -929,7 +1094,10 @@ function applyStatus(s) {
|
|
|
929
1094
|
renderEffortMenu();
|
|
930
1095
|
$("new-chat").disabled = !!s.busy;
|
|
931
1096
|
$("stop").hidden = !s.busy || !!s.switching;
|
|
932
|
-
$("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…"
|
|
1097
|
+
$("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : s.busy ? "The assistant is working…"
|
|
1098
|
+
: s.rendering ? `Rendering ${s.source} with Quarto…` : "";
|
|
1099
|
+
$("working").hidden = !s.busy && !s.rendering;
|
|
1100
|
+
if (s.source) $("deck-source").textContent = s.source;
|
|
933
1101
|
$("conn").classList.toggle("on", !!s.running);
|
|
934
1102
|
$("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
|
|
935
1103
|
// only the selected backend's availability matters: Codex missing is no news to someone using Claude
|
|
@@ -1005,7 +1173,9 @@ function connect() {
|
|
|
1005
1173
|
d.chat.forEach(renderEntry);
|
|
1006
1174
|
applyStatus(d.status);
|
|
1007
1175
|
scrollChat();
|
|
1176
|
+
api("comments").then((r) => r.json()).then(applyComments).catch(() => {});
|
|
1008
1177
|
},
|
|
1178
|
+
comments: (v) => applyComments(v),
|
|
1009
1179
|
chat(e) {
|
|
1010
1180
|
const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
|
|
1011
1181
|
if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
|
|
@@ -1239,6 +1409,7 @@ $("provider-switch").addEventListener("close", async () => {
|
|
|
1239
1409
|
let deleting = false;
|
|
1240
1410
|
async function requestDelete(eid) {
|
|
1241
1411
|
if (deleting || !eid) return;
|
|
1412
|
+
if (quartoWhy()) return toast(quartoWhy());
|
|
1242
1413
|
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
1243
1414
|
deleting = true;
|
|
1244
1415
|
try {
|
package/public/index.html
CHANGED
|
@@ -91,25 +91,43 @@
|
|
|
91
91
|
</span>
|
|
92
92
|
<span class="head-actions">
|
|
93
93
|
<span id="conn" class="conn" title="Backend session"></span>
|
|
94
|
+
<button id="comments-btn" class="small" aria-expanded="false" aria-controls="comments-panel"
|
|
95
|
+
title="Review comments: notes on the slides, kept for later">Comments <span id="comments-count" class="count"></span></button>
|
|
94
96
|
<button id="new-chat" class="small" title="Start a new conversation">New chat</button>
|
|
95
97
|
</span>
|
|
96
98
|
</div>
|
|
99
|
+
<section id="comments-panel" class="comments-panel" aria-label="Review comments" hidden>
|
|
100
|
+
<div class="comments-head">
|
|
101
|
+
<strong>Comments</strong>
|
|
102
|
+
<label class="check"><input type="checkbox" id="comments-resolved"> Show resolved</label>
|
|
103
|
+
<span class="spacer"></span>
|
|
104
|
+
<button id="comments-send" class="small" title="Put the open comments into the message box as one request">Process Comments</button>
|
|
105
|
+
</div>
|
|
106
|
+
<p id="comments-empty" class="muted comments-empty">No comments yet. Write a note in the box below, pin an element (or not, for the
|
|
107
|
+
whole slide) and press <strong>Comment</strong> instead of Send: it is kept here, and on the slide, for later.</p>
|
|
108
|
+
<ol id="comment-list" class="comment-list"></ol>
|
|
109
|
+
<p id="comments-file" class="muted comments-file"></p>
|
|
110
|
+
</section>
|
|
97
111
|
<div id="chat" class="chat" aria-live="polite">
|
|
98
112
|
<div class="empty" id="chat-empty">
|
|
99
113
|
<p><strong>Pin, then ask.</strong></p>
|
|
100
114
|
<p>Press <kbd>P</kbd> and click elements on the slide. Each click gets a number. Then write what to change, e.g.
|
|
101
115
|
<em>“1: align with 2, make 3 smaller”</em>.</p>
|
|
102
|
-
<p>Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
|
|
116
|
+
<p class="hand-only">Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
|
|
103
117
|
double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
|
|
104
118
|
<p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
|
|
105
119
|
or, for a paper or report, take the details from it.</p>
|
|
106
|
-
<p>The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
|
|
120
|
+
<p class="hand-only">The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
|
|
107
121
|
<code>brand.css</code> is locked.</p>
|
|
122
|
+
<p class="quarto-only">This deck is made with Quarto: the assistant edits <code id="deck-source"></code> and its stylesheets, and PinDeck renders
|
|
123
|
+
the deck after every change. Edit the source yourself in any editor; the slide updates here too.</p>
|
|
108
124
|
</div>
|
|
109
125
|
</div>
|
|
110
126
|
<div id="drop-hint" class="drop-hint" hidden><div>Drop figures, audio or PDFs to attach them</div></div>
|
|
111
127
|
<div id="working" class="working" hidden><span class="spinner"></span> <span id="working-text">The assistant is working…</span></div>
|
|
112
128
|
<div class="composer">
|
|
129
|
+
<div id="input-split" class="notes-split" role="separator" aria-orientation="horizontal" aria-controls="input" tabindex="0"
|
|
130
|
+
title="Drag to resize the message box (double-click: default size)" aria-label="Resize the message box"></div>
|
|
113
131
|
<div id="pin-tray" class="pin-tray"></div>
|
|
114
132
|
<div id="attach-tray" class="attach-tray"></div>
|
|
115
133
|
<textarea id="input" rows="3" placeholder="Ask for a change… refer to pins by number (1, 2, …)"></textarea>
|
|
@@ -125,6 +143,7 @@
|
|
|
125
143
|
<input type="file" id="file-input" multiple accept="image/*,audio/*,.svg,.pdf,application/pdf" hidden>
|
|
126
144
|
<span class="spacer"></span>
|
|
127
145
|
<button id="clear-pins" class="small" hidden>Clear pins</button>
|
|
146
|
+
<button id="comment" title="Keep the text as a review comment on the pin (or on the slide), instead of sending it">Comment</button>
|
|
128
147
|
<button id="send" class="primary">Send</button>
|
|
129
148
|
<button id="stop" class="danger" hidden>Stop</button>
|
|
130
149
|
</div>
|