@chalky/chalky 0.1.5 → 0.1.7

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.
@@ -175,13 +175,14 @@ export default async function (page) {
175
175
  const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
176
176
 
177
177
  // ---- where things are ----
178
+ const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js): window px per main px (and per app-frame px)
178
179
  const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
179
- const m = area.getBoundingClientRect();
180
- return { x: x - m.left + area.scrollLeft, y: y - m.top + area.scrollTop };
180
+ const m = area.getBoundingClientRect(), z = zoom();
181
+ return { x: (x - m.left) / z + area.scrollLeft, y: (y - m.top) / z + area.scrollTop };
181
182
  };
182
183
  const inWindow = (p) => {
183
- const m = area.getBoundingClientRect();
184
- return { x: p.x + m.left - area.scrollLeft, y: p.y + m.top - area.scrollTop };
184
+ const m = area.getBoundingClientRect(), z = zoom();
185
+ return { x: m.left + (p.x - area.scrollLeft) * z, y: m.top + (p.y - area.scrollTop) * z };
185
186
  };
186
187
  const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
187
188
 
@@ -266,7 +267,7 @@ export default async function (page) {
266
267
  const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
267
268
  const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
268
269
  board: Number(document.body.dataset.board) || 1, // the board shown (board/)
269
- area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
270
+ area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w / zoom()), height: Math.round(box.h / zoom()) } };
270
271
  step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
271
272
  steps.push(step);
272
273
  for (const m of marks) m.el.remove();
@@ -303,10 +304,10 @@ export default async function (page) {
303
304
  doc.head.append(s);
304
305
  classes(doc.documentElement);
305
306
  }
306
- const offset = () => {
307
- if (!frameEl) return { x: 0, y: 0 };
308
- const r = frameEl.getBoundingClientRect();
309
- return { x: r.left + frameEl.clientLeft, y: r.top + frameEl.clientTop };
307
+ const offset = () => { // an app frame's (0, 0) in window px, and window px per frame px (k: zoomed with the main space)
308
+ if (!frameEl) return { x: 0, y: 0, k: 1 };
309
+ const r = frameEl.getBoundingClientRect(), k = zoom();
310
+ return { x: r.left + frameEl.clientLeft * k, y: r.top + frameEl.clientTop * k, k };
310
311
  };
311
312
  const root = frameEl ? doc : area;
312
313
  root.addEventListener("pointerdown", (e) => {
@@ -316,16 +317,16 @@ export default async function (page) {
316
317
  if (erasing) { // the press is for the eraser, not for what's under it
317
318
  e.preventDefault();
318
319
  press = { erase: true, win };
319
- eraseAt(inMain(e.clientX + o.x, e.clientY + o.y));
320
+ eraseAt(inMain(o.x + e.clientX * o.k, o.y + e.clientY * o.k));
320
321
  return;
321
322
  }
322
323
  grabbed = marks.length ? frame() : null; // before the click acts
323
324
  if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
324
325
  if (texting) e.preventDefault(); // the press is for the words (or a line), not for what's under it
325
- press = { x: e.clientX + o.x, y: e.clientY + o.y, id: e.pointerId, target: e.target, win, o, text: texting };
326
+ press = { x: o.x + e.clientX * o.k, y: o.y + e.clientY * o.k, id: e.pointerId, target: e.target, win, o, text: texting };
326
327
  }, true);
327
328
  win.addEventListener("pointermove", (e) => {
328
- const o = offset(), x = e.clientX + o.x, y = e.clientY + o.y;
329
+ const o = offset(), x = o.x + e.clientX * o.k, y = o.y + e.clientY * o.k;
329
330
  over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
330
331
  if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
331
332
  if (!press || press.win !== win || !drawing) return;
@@ -495,7 +496,7 @@ export default async function (page) {
495
496
  hideAsk();
496
497
  const last = marks.at(-1).points.at(-1), to = document.getElementById("to")?.value || "agent1";
497
498
  ask = Object.assign(document.createElement("form"), { className: "doodle-ask" });
498
- const m = area.getBoundingClientRect(), room = inMain(m.left + area.clientWidth - 316, m.bottom - 140); // inside the view (not under the scrollbar)
499
+ const m = area.getBoundingClientRect(), z = zoom(), room = inMain(m.left + (area.clientWidth - 316) * z, m.bottom - 140 * z); // inside the view (not under the scrollbar)
499
500
  Object.assign(ask.style, { left: `${Math.min(last.x + 16, room.x)}px`, top: `${Math.min(last.y + 12, room.y)}px` });
500
501
  const field = Object.assign(document.createElement("input"), { placeholder: `Say something… (Enter sends to ${to})`, autocomplete: "off" });
501
502
  field.setAttribute("aria-label", "Words to send with your marks");
@@ -527,10 +528,11 @@ export default async function (page) {
527
528
  const distBox = (b, q) => Math.hypot(Math.max(b.x - q.x, 0, q.x - b.x - b.w), Math.max(b.y - q.y, 0, q.y - b.y - b.h));
528
529
  const inPoly = (pts, q) => { let c = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const a = pts[i], b = pts[j];
529
530
  if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) c = !c; } return c; };
530
- const frameOffset = (fr) => { const r = fr.getBoundingClientRect(); return { x: r.left + fr.clientLeft, y: r.top + fr.clientTop }; };
531
- function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in
532
- const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0 }, p = inMain(r.left + o.x, r.top + o.y);
533
- return { x: p.x, y: p.y, w: r.width, h: r.height };
531
+ const frameOffset = (fr) => { const r = fr.getBoundingClientRect(), k = zoom(); return { x: r.left + fr.clientLeft * k, y: r.top + fr.clientTop * k, k }; };
532
+ function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in (its px: main px)
533
+ const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0, k: 1 }, p = inMain(o.x + r.left * o.k, o.y + r.top * o.k);
534
+ const s = fr ? 1 : 1 / zoom(); // window px -> main px
535
+ return { x: p.x, y: p.y, w: r.width * s, h: r.height * s };
534
536
  }
535
537
  const scopes = (el) => [{ root: el, fr: null }, ...[...(el.localName === "iframe" ? [el] : el.querySelectorAll("iframe"))].flatMap((fr) => {
536
538
  try { return fr.contentDocument?.body ? [{ root: fr.contentDocument.body, fr }] : []; } catch { return []; } // other origins: not ours
@@ -671,7 +673,7 @@ export default async function (page) {
671
673
  if (under && !area.contains(under) && under !== document.documentElement && under !== document.body)
672
674
  return { box: { x, y, w, h }, board: Number(board()), fixed: true };
673
675
  const p = inMain(x, y);
674
- return { box: { x: p.x, y: p.y, w, h }, board: Number(board()) };
676
+ return { box: { x: p.x, y: p.y, w: w / zoom(), h: h / zoom() }, board: Number(board()) };
675
677
  }
676
678
  function findMain(t, every) {
677
679
  if (t.mark) {
@@ -3,6 +3,8 @@
3
3
  // Files dragged from the computer onto the page: uploaded (POST /file/upload), then a "file-dropped"
4
4
  // DOM event {x, y, files: [{name, path, size}]} at the drop spot (the note extension opens its note box
5
5
  // there). Dropped onto the panel itself: only stored.
6
+ // Pasted (Ctrl+V / ⌘V) with files on the clipboard: the same as a drop where the pointer last was (copied
7
+ // images, all named image.png, become pasted-<date>-<time>.png). Text pastes as usual.
6
8
  // A row dragged out of the panel onto the page: the same event, with that file.
7
9
  // A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
8
10
  // {file, row} (the viewer extension opens a preview window attached to the row; rows carry data-path).
@@ -42,7 +44,11 @@ export default async function (page) {
42
44
  #side .file-bar button { font: inherit; color: var(--text); background: var(--glass-strong); border: 1px solid var(--line);
43
45
  border-radius: 8px; padding: 3px 10px; cursor: pointer; }
44
46
  #side .file-bar .delete { color: #fff; background: var(--danger); border-color: transparent; }
45
- #side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }`;
47
+ #side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
48
+ .file-uploading { position: fixed; z-index: 200000; max-width: 240px; padding: 5px 10px; border-radius: 10px; font-size: 12px;
49
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
50
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
51
+ border: 1px solid var(--edge); box-shadow: var(--shadow); }`;
46
52
  document.head.append(style);
47
53
 
48
54
  const toggle = document.createElement("button");
@@ -129,15 +135,48 @@ export default async function (page) {
129
135
  side.removeAttribute("data-over");
130
136
  const { clientX: x, clientY: y } = e;
131
137
  if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
132
- const stored = [];
133
- for (const f of [...e.dataTransfer.files].slice(0, 10)) {
138
+ store([...e.dataTransfer.files], x, y, onPanel(e));
139
+ });
140
+
141
+ // Upload files (at most MAX), then preview the first and fire "file-dropped" at (x, y), unless they landed on the panel
142
+ const MAX = 10;
143
+ async function store(all, x, y, panel, names = all.map((f) => f.name)) {
144
+ if (all.length > MAX) page.notice([`You added ${all.length} files: only the first ${MAX} were kept.`]);
145
+ const stored = [], files = all.slice(0, MAX), size = files.reduce((n, f) => n + f.size, 0);
146
+ const tag = Object.assign(document.createElement("div"), { className: "file-uploading",
147
+ textContent: `Uploading ${files.length > 1 ? `${files.length} files` : names[0]} · ${Math.ceil(size / 1024)} KB` });
148
+ Object.assign(tag.style, { left: `${Math.min(x + 14, innerWidth - 260)}px`, top: `${Math.min(y + 16, innerHeight - 40)}px` });
149
+ const slow = setTimeout(() => document.body.append(tag), 500); // only when it takes a while
150
+ for (const [i, f] of files.entries()) {
134
151
  try {
135
- const out = await (await fetch(`/file/upload?name=${encodeURIComponent(f.name)}`, { method: "POST", body: f })).json();
152
+ const out = await (await fetch(`/file/upload?name=${encodeURIComponent(names[i])}`, { method: "POST", body: f })).json();
136
153
  if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
137
- else page.note(`Could not upload ${f.name}: ${out.error}`);
138
- } catch (err) { page.note(`Could not upload ${f.name}: ${err}`); }
154
+ else page.notice([`Could not upload ${names[i]}: ${out.error}`]);
155
+ } catch (err) { page.notice([`Could not upload ${names[i]}: ${err}`]); }
139
156
  }
157
+ clearTimeout(slow); tag.remove();
140
158
  if (stored.length) opened(stored[0]); // the list has its row by now (the "file" event comes before the reply)
141
- if (stored.length && !onPanel(e)) dropped(x, y, stored);
159
+ if (stored.length && !panel) dropped(x, y, stored);
160
+ }
161
+
162
+ // Paste (Ctrl+V / ⌘V): files on the clipboard are added as if dropped where the pointer last was.
163
+ // Text pastes as usual. A paste inside a card's app goes to that app (its own page): Chalky never hears it.
164
+ let pointer = { x: innerWidth / 2, y: innerHeight / 2 };
165
+ document.addEventListener("pointermove", (e) => { pointer = { x: e.clientX, y: e.clientY }; }, { passive: true });
166
+ document.addEventListener("paste", (e) => {
167
+ const files = [...(e.clipboardData?.files || [])];
168
+ if (!files.length) { // a Linux file manager copies the file's address, not the file
169
+ const text = e.clipboardData?.getData("text/plain") ?? "";
170
+ const typing = e.target.closest?.("input, textarea, [contenteditable]");
171
+ if (!typing && /^file:\/\//.test(text.trim())) page.notice(["You copied a file's address, not the file. Drag the file onto the page instead."]);
172
+ return;
173
+ }
174
+ e.preventDefault();
175
+ const d = new Date(), p = (n) => String(n).padStart(2, "0");
176
+ const stamp = `pasted-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
177
+ let n = 0; // every copied image arrives as image.png
178
+ const names = files.map((f) => (!f.name || f.name === "image.png") ? `${stamp}${n++ ? `-${n}` : ""}.${f.type.split("/")[1] || "png"}` : f.name);
179
+ const { x, y } = pointer;
180
+ store(files, x, y, side.contains(document.elementFromPoint(x, y)), names);
142
181
  });
143
182
  }
@@ -3,7 +3,8 @@
3
3
  // the page (from the computer: uploaded here, listed, and a "file-dropped" DOM event so note's browser.js opens its note
4
4
  // box there; onto the panel itself: only stored; a row from the panel: the same event with that file).
5
5
  // The agent always knows the folder (its prompt section); files it saves there show in the panel too (the list is
6
- // sent again when an agent's turn is over: pi's agent_settled).
6
+ // sent again after each of its tool calls, pi's tool_execution_end, if it changed: a file it saves shows at once,
7
+ // while it is still working).
7
8
  // POST /file/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
8
9
  // GET /file [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
9
10
  // POST /file/delete {names: [...]} (names as listed): deleted for good, returns {deleted, errors}
@@ -37,7 +38,7 @@ function changed(always = false) { // tell the pages, if the list is not what t
37
38
  }
38
39
 
39
40
  export default function (pi: ExtensionAPI) {
40
- pi.on("agent_settled", () => changed()); // the agent may have saved files there
41
+ pi.on("tool_execution_end", () => changed()); // the agent may have saved files there
41
42
  if (!host.shared.has("file-last")) host.shared.set("file-last", JSON.stringify(listing()));
42
43
 
43
44
  host.routes.set("/file", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
@@ -24,8 +24,8 @@ one design, not separate requests: they share one look, and often each other's d
24
24
  on the page, with no agent in between. Separate block cards with {"ref"} props are for a frame that only
25
25
  shows another's data (refs only read). Don't change the page's own code (page.html, the browser.js files) to
26
26
  link frames.
27
- - Fill a frame with card_show(card=<its id>, place=<its place>, ...): its dashed box goes when that
28
- card appears. Keep the place: the user chose where and how big.
27
+ - Fill a frame with card_show(place=<its place>, ...), with any card id (a name for what it is): its
28
+ dashed box goes when a card appears exactly there. Keep the place: the user chose where and how big.
29
29
  - Split the work when it pays: several frames that are each real work and don't depend on each other can
30
30
  go to helpers in parallel (team_spawn, or idle agents you have): tell each one its frame, and what done
31
31
  means: "card <id> shown at <place>, doing <their words>". Say the shared look in each message
@@ -71,9 +71,10 @@ export default async function (page) {
71
71
  const board = () => Number(document.body.dataset.board) || 1; // the board shown (board/): frames are drawn and sent there
72
72
  document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
73
73
 
74
+ const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js)
74
75
  const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
75
- const m = area.getBoundingClientRect();
76
- return { x: Math.round(x - m.left + area.scrollLeft), y: Math.round(y - m.top + area.scrollTop) };
76
+ const m = area.getBoundingClientRect(), z = zoom();
77
+ return { x: Math.round((x - m.left) / z + area.scrollLeft), y: Math.round((y - m.top) / z + area.scrollTop) };
77
78
  };
78
79
  const put = (box, f) => Object.assign(box.style, { left: `${f.x}px`, top: `${f.y}px`, width: `${f.width}px`, height: `${f.height}px` });
79
80
 
@@ -154,8 +155,9 @@ export default async function (page) {
154
155
  const corner = e.currentTarget, x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
155
156
  corner.setPointerCapture(e.pointerId);
156
157
  let place = null;
158
+ const z = zoom(); // window px moved -> main px
157
159
  corner.onpointermove = (ev) => {
158
- place = { x, y, width: Math.max(40, Math.round(w0 + ev.clientX - e.clientX)), height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
160
+ place = { x, y, width: Math.max(40, Math.round(w0 + (ev.clientX - e.clientX) / z)), height: Math.max(30, Math.round(h0 + (ev.clientY - e.clientY) / z)) };
159
161
  put(box, place);
160
162
  };
161
163
  corner.onpointerup = () => {
@@ -3,12 +3,12 @@
3
3
  // so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
4
4
  // A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where card_show's place puts a card;
5
5
  // board: the one it was drawn on, board/; Implement sends the frames of the board shown), with an
6
- // id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(card="frame-1", place=<its place>);
7
- // when that card appears (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
6
+ // id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(place=<its place>) (any card id);
7
+ // when a card appears at its place (or with its id) (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
8
8
  // can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
9
9
  // GET /frame {id: frame}
10
10
  // page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
11
- // frame_remove {id}, frame_send {to, board}
11
+ // frame_remove {id}, frame_send {to, board, by?} (by: the agent whose code pressed Implement)
12
12
  // event "frame" {frames: {id: frame}} to every open page, when they changed
13
13
  // Saved in data/frame.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
14
14
  import fs from "node:fs";
@@ -32,10 +32,17 @@ const place = (f: any) => JSON.stringify({ x: f.x, y: f.y, width: f.width, heigh
32
32
 
33
33
  export default function (pi: ExtensionAPI) {
34
34
 
35
- // a frame is done when a card with its id appears (after all extensions loaded: the card one may come later)
35
+ // a frame is done when a card appears at its place on its board, or with its id (after all extensions loaded: the
36
+ // card one may come later). The place is what the user chose; agents often give the card a name of its own.
36
37
  pi.on("session_start", () => {
37
38
  host.api.card?.watch("frame", (sid: string) => {
38
- if (frames[sid] && host.shared.get("card")?.cards[sid]) { delete frames[sid]; changed(); }
39
+ const card = host.shared.get("card")?.cards[sid];
40
+ if (!card) return;
41
+ const at = (f: any) => card.place && (card.board ?? 1) === (f.board ?? 1)
42
+ && ["x", "y", "width", "height"].every((k) => Math.round(card.place[k]) === f[k]);
43
+ const done = Object.keys(frames).filter((id) => id === sid || at(frames[id]));
44
+ for (const id of done) delete frames[id];
45
+ if (done.length) changed();
39
46
  });
40
47
  host.api.board?.onRemove("frame", (n: number) => { // a removed board (board/) takes its frames with it
41
48
  const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
@@ -70,9 +77,11 @@ export default function (pi: ExtensionAPI) {
70
77
  if (!ready.length) return;
71
78
  for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
72
79
  changed();
73
- const lines = [`[frames on the page] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
80
+ // by: an agent's code pressed Implement (page/browser.js), not the user
81
+ const who = !msg.by ? "the user laid out" : msg.by === to ? "you pressed Implement for" : `${msg.by} pressed Implement for`;
82
+ const lines = [`[frames on the page] ${who} ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
74
83
  ...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
75
- ` Fill each with card_show(card=<its id>, place=<its place>, board=${board}, ...); its dashed box goes when it appears.`];
84
+ ` Fill each with card_show(place=<its place>, board=${board}, ...) (any card id): its dashed box goes when a card appears there.`];
76
85
  const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
77
86
  if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
78
87
  team.send("user", to, lines.join("\n"));
@@ -88,7 +88,9 @@ export default function (pi: ExtensionAPI) {
88
88
  pi.on("session_start", () => {
89
89
  if (!host.shared.has("history")) host.shared.set("history", new History(host.shared.get("card")));
90
90
  host.api.history = host.shared.get("history");
91
- host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { if (what) host.api.history.add(by, what); });
91
+ host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { // the card's words don't say who
92
+ if (what) host.api.history.add(by, `${by === "user" ? "you" : by} ${what}`);
93
+ });
92
94
  });
93
95
  const shared = (): History | undefined => host.shared.get("history");
94
96
 
@@ -32,4 +32,6 @@ thing has partAt), its text, and the file it comes from. Act on that exact thing
32
32
  pointer ("this one") use the pen (doodle_mark) instead.
33
33
  - When you did what a note asked, note_done(id): its pin turns into a faded ✓, which the user can reopen or delete. If your
34
34
  answer leaves anything to them (you suggest something, ask, or offer a choice), leave the note open: their reply
35
- comes in the same thread, and you close it once that is settled. The user can close one too (✓ Done); you get an fyi.
35
+ comes in the same thread, and you close it once that is settled. The user can close one too (✓) or delete it (✕);
36
+ you get an fyi. A note you started that is no longer needed (it explained a step, the step is past) can go for
37
+ good: note_done(id, delete: true). Notes the user or another agent started you only close.
@@ -1,5 +1,5 @@
1
- // The page side of the note extension. A pencil button in the tool palette (top) turns on a clear
2
- // layer over the page; the cursor is a pen, blue over something a click would point at.
1
+ // The page side of the note extension. A speech-bubble button in the tool palette (top) turns on a clear
2
+ // layer over the page; the cursor is a speech bubble, blue over something a click would point at.
3
3
  // Click: a note box opens there. Hold the button and draw: on release the note box opens, and the note
4
4
  // covers the drawn area (its bounding box, in main-space px: where card_show's place puts a card).
5
5
  // The user sees only the box; the target goes to the agent with the note: card and block (from
@@ -9,7 +9,8 @@
9
9
  // tells every open page to pin it, with its drawing). Esc or the button turns it off.
10
10
  // Pins: a numbered pin per note (the agents' in their colour), on the thing it is on, scrolling with the main space
11
11
  // and moving with its card. Click one for its thread: who wrote what, a reply box (Enter sends it to the note's
12
- // agent), ✓ Done; a closed note stays as a faded ✓ (Reopen, or Delete for good). An agent's reply opens the bubble while
12
+ // agent), ✓ (close) and ✕ (delete) at its top right; a closed note stays as a faded ✓ (Reopen). ✕ asks nothing: the pin
13
+ // goes, a notice offers Undo for a few seconds, and only then is the delete sent. An agent's new note, or its reply, opens the bubble while
13
14
  // the note stays open. Show marks (doodle/)
14
15
  // hides the pins with the other marks.
15
16
  // The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
@@ -27,11 +28,11 @@ export default async function (page) {
27
28
 
28
29
  const layer = document.createElement("div"); // catches clicks, so the page underneath doesn't react
29
30
  layer.style.cssText = "position:fixed;inset:0;z-index:9998;display:none";
30
- // The cursor: the button's pen (white edge, so it shows on dark too), its tip (4, 20) is the spot pointed at.
31
+ // The cursor: the button's speech bubble (white edge, so it shows on dark too); its tail's tip (2, 22) is the spot pointed at.
32
+ const BUBBLE = "M7.9 20A9 9 0 1 0 4 16.1L2 22z";
31
33
  const pen = (color) => `url("data:image/svg+xml,${encodeURIComponent(
32
34
  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
33
- `<g stroke="#fff" stroke-width="4"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g>` +
34
- `<g stroke="${color}" stroke-width="2"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g></svg>`)}") 4 20, crosshair`;
35
+ `<path d="${BUBBLE}" stroke="#fff" stroke-width="4"/><path d="${BUBBLE}" stroke="${color}" stroke-width="2"/></svg>`)}") 2 22, crosshair`;
35
36
  const PEN = pen("#6e6e73"), PEN_ON = pen("#0071e3"); // grey: empty space; blue: over a thing
36
37
  layer.style.cursor = PEN;
37
38
  const input = document.createElement("input"); // the note box, opened where the user clicked
@@ -59,7 +60,7 @@ export default async function (page) {
59
60
  document.body.append(layer, ink, input);
60
61
 
61
62
  const button = document.createElement("button");
62
- button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></svg>`;
63
+ button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${BUBBLE}"/></svg>`; // a speech bubble
63
64
  button.title = "Point: click anything, or hold and draw around an area, then write a note (Esc to stop)";
64
65
  button.dataset.name = "note"; // names an agent's pen can point at
65
66
  page.slot("status").prepend(button); // the first tool in the palette, whichever script loads first
@@ -78,8 +79,13 @@ export default async function (page) {
78
79
  [...e.classList].slice(0, 2).map((c) => `.${c}`).join("");
79
80
 
80
81
  function inMain(x, y) { // window px -> main-space px (the main space scrolls; card_show's place uses these)
81
- const main = page.slot("top"), m = main.getBoundingClientRect();
82
- return { x: Math.round(x - m.left + main.scrollLeft), y: Math.round(y - m.top + main.scrollTop) };
82
+ const main = page.slot("top"), m = main.getBoundingClientRect(), z = zoom(); // the board's zoom (board/browser.js)
83
+ return { x: Math.round((x - m.left) / z + main.scrollLeft), y: Math.round((y - m.top) / z + main.scrollTop) };
84
+ }
85
+ function zoom() { return parseFloat(page.slot("top").style.zoom) || 1; } // window px per main px (and per app-frame px)
86
+ function inFrame(fr, x, y) { // window px -> px in an app's frame (zoomed with the main space)
87
+ const r = fr.getBoundingClientRect(), z = zoom();
88
+ return [(x - r.left) / z - fr.clientLeft, (y - r.top) / z - fr.clientTop];
83
89
  }
84
90
 
85
91
  function target(x, y) {
@@ -88,9 +94,9 @@ export default async function (page) {
88
94
  let inner = null, frame = null, box = el.getBoundingClientRect();
89
95
  if (el.localName === "iframe") { // look inside the frame (an App): same origin, so its document is readable
90
96
  try {
91
- const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
92
- inner = el.contentDocument?.elementFromPoint(x - dx, y - dy);
93
- if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(b.x + dx, b.y + dy, b.width, b.height); }
97
+ const r = el.getBoundingClientRect(), z = zoom(), dx = r.left + el.clientLeft * z, dy = r.top + el.clientTop * z;
98
+ inner = el.contentDocument?.elementFromPoint(...inFrame(el, x, y));
99
+ if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(dx + b.x * z, dy + b.y * z, b.width * z, b.height * z); }
94
100
  } catch {}
95
101
  }
96
102
  const hitEl = inner || el;
@@ -109,8 +115,7 @@ export default async function (page) {
109
115
  let part = null; // what the thing itself says is at this spot (partAt), e.g. {"name": "enemy-2"}
110
116
  try {
111
117
  if (frame?.contentWindow?.partAt) {
112
- const r = frame.getBoundingClientRect();
113
- part = frame.contentWindow.partAt(x - r.left - frame.clientLeft, y - r.top - frame.clientTop);
118
+ part = frame.contentWindow.partAt(...inFrame(frame, x, y));
114
119
  } else {
115
120
  const owner = [hitEl, ...[...document.elementsFromPoint(x, y)]].find((e) => typeof e?.partAt === "function");
116
121
  if (owner) { const r = owner.getBoundingClientRect(); part = owner.partAt(x - r.left, y - r.top); }
@@ -187,8 +192,7 @@ export default async function (page) {
187
192
  const el = document.elementsFromPoint(x, y).find((e) => e !== layer && e !== ink);
188
193
  if (el?.localName === "iframe") {
189
194
  try {
190
- const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
191
- if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => [px - dx, py - dy]));
195
+ if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => inFrame(el, px, py)));
192
196
  } catch (err) { console.warn("partIn:", err); }
193
197
  }
194
198
  const owner = document.elementsFromPoint(x, y).find((e) => typeof e.partIn === "function");
@@ -203,7 +207,7 @@ export default async function (page) {
203
207
  const left = Math.min(...xs), top = Math.min(...ys), width = Math.max(...xs) - left, height = Math.max(...ys) - top;
204
208
  if (width < 12 && height < 12) return openNote(target(e.clientX, e.clientY), e.clientX, e.clientY);
205
209
  const t = target(left + width / 2, top + height / 2); // what is under the middle of the drawing
206
- if (t) t.area = { ...inMain(left, top), width: Math.round(width), height: Math.round(height) };
210
+ if (t) t.area = { ...inMain(left, top), width: Math.round(width / zoom()), height: Math.round(height / zoom()) };
207
211
  const inside = t && (await partIn(left + width / 2, top + height / 2, points));
208
212
  if (inside) t.part = inside;
209
213
  openNote(t, e.clientX, e.clientY, points);
@@ -246,6 +250,9 @@ export default async function (page) {
246
250
  .note-pin .bubble .busy { color: var(--muted, #888); font-style: italic; }
247
251
  .note-pin .bubble textarea { width: 100%; box-sizing: border-box; min-height: 48px; resize: vertical; font: inherit; padding: 6px 8px;
248
252
  border-radius: 8px; border: 1px solid rgba(0, 0, 0, .15); outline: none; background: rgba(255, 255, 255, .8); }
253
+ .note-pin .bubble .top { float: right; display: flex; gap: 2px; margin: -4px -6px 0 6px; }
254
+ .note-pin .bubble .top button { width: 24px; height: 24px; border-radius: 6px; font-size: 13px; color: var(--soft, #555); }
255
+ .note-pin .bubble .top button:hover { background: var(--hover, rgba(0, 0, 0, .05)); color: var(--text, #1d1d1f); }
249
256
  .note-pin .bubble .acts { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
250
257
  .note-pin .bubble .acts button { padding: 3px 10px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(0, 0, 0, .12); }
251
258
  .note-pin .bubble .acts button.primary { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
@@ -262,7 +269,21 @@ export default async function (page) {
262
269
  if (card) { const r = card.getBoundingClientRect(), c = inMain(r.left, r.top); return { x: c.x + n.anchor.dx, y: c.y + n.anchor.dy }; }
263
270
  return n.at ?? { x: 0, y: 0 };
264
271
  }
272
+ // Deleting: the pin goes at once, and a notice offers Undo for UNDO_MS; only then is the delete sent (so an undo
273
+ // needs nothing from the server, and other pages keep the note until it is really gone)
274
+ const UNDO_MS = 6000;
275
+ const leaving = {}; // id -> timer, while its undo is offered
276
+ function remove(id) {
277
+ openId = null;
278
+ pins[id]?.remove(); delete pins[id];
279
+ const undo = Object.assign(document.createElement("button"), { type: "button", className: "go", textContent: "Undo" });
280
+ const notice = page.notice(["Note deleted", undo], () => {});
281
+ const gone = () => { delete leaving[id]; notice.close(); page.send({ type: "note_delete", id }); };
282
+ leaving[id] = setTimeout(gone, UNDO_MS);
283
+ undo.onclick = () => { clearTimeout(leaving[id]); delete leaving[id]; notice.close(); if (notes[id]) draw(id); };
284
+ }
265
285
  function draw(id) {
286
+ if (id in leaving) return; // deleted, its undo still offered
266
287
  const n = notes[id];
267
288
  let el = pins[id];
268
289
  if (!el) {
@@ -283,6 +304,7 @@ export default async function (page) {
283
304
  }
284
305
  const b = Object.assign(document.createElement("button"), { className: "pin", type: "button", textContent: n.state === "open" ? id.slice(1) : "✓",
285
306
  title: `${n.thread[0]?.text ?? ""}${n.state === "open" ? "" : " (closed)"}` });
307
+ b.setAttribute("aria-label", `note ${id}${n.state === "open" ? "" : " (closed)"}: ${n.thread[0]?.text ?? ""}`); // what read() and screen readers say
286
308
  Object.assign(b.style, { left: `${p.x}px`, top: `${p.y}px` });
287
309
  b.onclick = (e) => { e.stopPropagation(); openId = openId === id ? null : id; for (const k of Object.keys(pins)) draw(k); };
288
310
  el.append(b);
@@ -309,6 +331,16 @@ export default async function (page) {
309
331
  b.onclick = fn;
310
332
  acts.append(b);
311
333
  };
334
+ // top right: ✓ closes (a faded pin, can be reopened), ✕ deletes (no question: a few seconds to undo instead)
335
+ const top = Object.assign(document.createElement("div"), { className: "top" });
336
+ const corner = (text, title, fn) => {
337
+ const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, title });
338
+ b.onclick = fn;
339
+ top.append(b);
340
+ };
341
+ if (n.state === "open") corner("✓", "Done: close this note", () => { openId = null; page.send({ type: "note_close", id }); });
342
+ corner("✕", "Delete this note", () => remove(id));
343
+ d.prepend(top);
312
344
  if (n.state === "open") {
313
345
  const ta = Object.assign(document.createElement("textarea"), { placeholder: "Reply… (Enter sends)", value: drafts[id] ?? "" });
314
346
  ta.oninput = () => { drafts[id] = ta.value; };
@@ -319,13 +351,9 @@ export default async function (page) {
319
351
  if (e.key === "Escape") { openId = null; draw(id); }
320
352
  };
321
353
  d.append(ta);
322
- button("✓ Done", () => { openId = null; page.send({ type: "note_close", id }); });
323
354
  button("Reply", go, true);
324
355
  setTimeout(() => ta.focus(), 0);
325
- } else {
326
- button("Delete", () => { if (confirm("Delete this note for good?")) { openId = null; page.send({ type: "note_delete", id }); } });
327
- button("Reopen", () => page.send({ type: "note_reopen", id }));
328
- }
356
+ } else button("Reopen", () => page.send({ type: "note_reopen", id }));
329
357
  d.append(acts);
330
358
  return d;
331
359
  }
@@ -338,8 +366,9 @@ export default async function (page) {
338
366
  setInterval(layout, 1500);
339
367
  page.on("note_pin", ({ id, type, agent, ts, ...n }) => {
340
368
  const reply = notes[id] && n.thread.length > notes[id].thread.length && n.thread.at(-1).by !== "user" && n.state === "open"; // an answer still waiting on the user
369
+ const started = !notes[id] && n.by !== "user" && n.state === "open"; // an agent's new note (the notes there at load come from /note/all)
341
370
  notes[id] = n;
342
- if (reply && openId !== id) openId = id; // an agent answered in the thread: show it
371
+ if ((reply || started) && openId !== id) openId = id; // an agent wrote: show it open, so the user sees it
343
372
  if (openId === id && n.state !== "open" && n.thread.at(-1)?.by !== "user") openId = null; // and closed it: done, so out of the way
344
373
  draw(id);
345
374
  });
@@ -4,5 +4,6 @@ A note is a thread pinned on a thing; you and the user can both start one. The u
4
4
  - Change that exact thing. If its text is data (a row, a score), change the card's data, not the block.
5
5
  - Answer in the note's thread (note_reply), not in chat. Ask or explain on a thing with note_add(on, text).
6
6
  - When you did what it asked, call note_done(id): the pin stays as a faded ✓ the user can reopen. If your answer
7
- leaves anything to them (a suggestion, a question, a choice), leave it open for their reply.
7
+ leaves anything to them (a suggestion, a question, a choice), leave it open for their reply. A note you started
8
+ that has served its purpose can go: note_done(id, delete: true).
8
9
  Before you handle your first note, read README.md.
@@ -3,13 +3,16 @@
3
3
  // note_add (page) click anything on the page (or draw around an area) and write: the target is the card,
4
4
  // block, element, part (partAt), its text and source file, the spot and the area.
5
5
  // Files dropped on the page come as a note too ("files": paths in <workspace>/drops).
6
+ // Written by an agent's code through the page (msg.by, page/browser.js): that agent's own note.
6
7
  // note_combine the user dragged card a onto card b and wrote how to combine them. The agent changes b to
7
8
  // take a in, reading a's data live ({"ref"} props); a stays as it is.
8
9
  // note_reply the user wrote in a note's thread (its bubble): it goes to the note's agent, with the thread so far
9
- // note_close / note_reopen / note_delete the user's ✓ Done in the bubble; Reopen or Delete (for good) on a closed one
10
+ // note_close / note_reopen / note_delete the user's ✓ (close) or ✕ (delete, for good; open or closed) at the
11
+ // bubble's top right, Reopen on a closed one
10
12
  // The agents' tools: note_add(on, text) starts a thread on a thing (on: as doodle_mark's, checked on the open page),
11
- // note_reply(id, text) writes in one, note_done(id) closes one. A closed note stays as a faded ✓ pin (the user can
12
- // reopen or delete it); the agents never delete notes, and a removed board takes its notes with it.
13
+ // note_reply(id, text) writes in one, note_done(id) closes one, note_done(id, delete: true) removes one the agent started
14
+ // (never the user's or another agent's). A closed note stays as a faded ✓ pin (the user can reopen or delete it); a
15
+ // removed board takes its notes with it.
13
16
  // Each note goes to the card's owner (else the agent picked in the chat; an agent's own note: that agent), gets an id
14
17
  // (n1, n2, ...: never reused) and is saved: data/note.json {"last", "notes": {id: {thread: [{by, text, ts}], to, by,
15
18
  // board, state: "open" | "done", at: {x, y} (main px), anchor: {card, dx, dy}, stroke, target?, box?}}}. The pin sits
@@ -40,6 +43,12 @@ const save = () => fs.writeFileSync(FILE, JSON.stringify(D));
40
43
  const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
41
44
  const show = (id: string) => host.broadcast("note_pin", { id, ...D.notes[id] });
42
45
  const opened = () => Object.keys(D.notes).filter((id) => D.notes[id].state === "open");
46
+ const remove = (id: string) => { // a note, for good: off every page
47
+ delete D.notes[id];
48
+ items.delete(id);
49
+ save();
50
+ host.broadcast("note_gone", { id });
51
+ };
43
52
 
44
53
  function pin(note: any) { // a new note: saved, pinned on every page
45
54
  const id = `n${++D.last}`;
@@ -114,10 +123,18 @@ export default function (pi: ExtensionAPI) {
114
123
 
115
124
  pi.registerTool({
116
125
  name: "note_done", label: "Note done", description: "Close a note that is dealt with (you did what it asked): its pin turns into a faded ✓ (the user can "
117
- + "reopen it). Not while your answer waits on the user (a suggestion, a question).",
118
- parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }) }),
126
+ + "reopen it). Not while your answer waits on the user (a suggestion, a question). With delete: true, a note you started "
127
+ + "goes from the page instead (open or closed), e.g. one that explained a step and has served its purpose.",
128
+ parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }),
129
+ delete: Type.Optional(Type.Boolean({ description: "remove it from the page for good: only notes you started" })) }),
119
130
  async execute(_id, a) {
120
131
  const n = D.notes[a.id];
132
+ if (a.delete) {
133
+ if (!n) return result(`error: no note '${a.id}'`);
134
+ if (n.by !== me()) return result(`error: ${a.id} was started by ${n.by === "user" ? "the user" : n.by}: only its starter deletes it. Close it instead (note_done without delete).`);
135
+ remove(a.id);
136
+ return result(`note ${a.id} is gone from the page`);
137
+ }
121
138
  if (!n || n.state !== "open") return result(`error: no open note '${a.id}'. Open notes: ${opened().join(", ") || "none"}`);
122
139
  n.state = "done";
123
140
  items.delete(a.id);
@@ -142,12 +159,11 @@ export default function (pi: ExtensionAPI) {
142
159
  return;
143
160
  }
144
161
  if (msg.type === "note_reopen" && n?.state === "done") { n.state = "open"; save(); return show(msg.id); }
145
- if (msg.type === "note_delete" && n?.state === "done") { // a closed note, for good
146
- delete D.notes[msg.id];
147
- items.delete(msg.id);
148
- save();
149
- host.broadcast("note_gone", { id: msg.id });
150
- if (host.agents.has(n.to)) team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page.`);
162
+ if (msg.type === "note_delete" && n) { // open or closed, for good
163
+ const item = items.get(msg.id);
164
+ remove(msg.id);
165
+ if (host.agents.has(n.to) && !(item && team?.withdraw(item))) // not seen yet: just take it back
166
+ team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page; drop it if you haven't finished it.`);
151
167
  return;
152
168
  }
153
169
  if (msg.type === "note_reply" && n && team && String(msg.text ?? "").trim()) {
@@ -163,10 +179,17 @@ export default function (pi: ExtensionAPI) {
163
179
  }
164
180
  if (!["note_add", "note_combine"].includes(msg.type) || !team) return;
165
181
  host.api.doodle?.dim();
166
- const to = host.agents.has(msg.to) ? msg.to : "agent1";
167
182
  const text = msg.text ?? "";
168
- const note = { to, by: "user", board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
183
+ const base = { board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
169
184
  stroke: msg.stroke ?? null }; // with its drawing, if any (main px)
185
+ // an agent's code wrote it through the page's tools (msg.by, page/browser.js): that agent's own note, as if it
186
+ // called note_add; nothing to tell it, it knows (the reply to page_run says what happened)
187
+ if (host.agents.has(msg.by)) {
188
+ pin({ ...base, to: msg.by, by: msg.by, thread: [{ by: msg.by, text: msg.type === "note_combine" ? `combine ${msg.a} into ${msg.b}: ${text}` : text, ts: Date.now() }] });
189
+ return;
190
+ }
191
+ const to = host.agents.has(msg.to) ? msg.to : "agent1";
192
+ const note = { ...base, to, by: "user" };
170
193
  if (msg.type === "note_combine") {
171
194
  const { a, b } = msg;
172
195
  const madeFrom = [...(host.shared.get("card")?.cards[b]?.made_from ?? []).filter((m: string) => m !== a), a];