@oharness/server 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/i18n.js +144 -0
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/main.js +20 -0
  7. package/dist/main.js.map +1 -1
  8. package/dist/page/client/account.d.ts +8 -0
  9. package/dist/page/client/account.js +1895 -0
  10. package/dist/page/client/account.js.map +1 -0
  11. package/dist/page/client/artifacts.d.ts +7 -0
  12. package/dist/page/client/artifacts.js +767 -0
  13. package/dist/page/client/artifacts.js.map +1 -0
  14. package/dist/page/client/base.d.ts +8 -0
  15. package/dist/page/client/base.js +400 -0
  16. package/dist/page/client/base.js.map +1 -0
  17. package/dist/page/client/browser.d.ts +21 -0
  18. package/dist/page/client/browser.js +904 -0
  19. package/dist/page/client/browser.js.map +1 -0
  20. package/dist/page/client/composer.d.ts +8 -0
  21. package/dist/page/client/composer.js +1694 -0
  22. package/dist/page/client/composer.js.map +1 -0
  23. package/dist/page/client/dialogs.d.ts +7 -0
  24. package/dist/page/client/dialogs.js +724 -0
  25. package/dist/page/client/dialogs.js.map +1 -0
  26. package/dist/page/client/events.d.ts +7 -0
  27. package/dist/page/client/events.js +288 -0
  28. package/dist/page/client/events.js.map +1 -0
  29. package/dist/page/client/index.d.ts +16 -0
  30. package/dist/page/client/index.js +46 -0
  31. package/dist/page/client/index.js.map +1 -0
  32. package/dist/page/client/panels.d.ts +7 -0
  33. package/dist/page/client/panels.js +659 -0
  34. package/dist/page/client/panels.js.map +1 -0
  35. package/dist/page/client/pickers.d.ts +7 -0
  36. package/dist/page/client/pickers.js +624 -0
  37. package/dist/page/client/pickers.js.map +1 -0
  38. package/dist/page/client/routines.d.ts +6 -0
  39. package/dist/page/client/routines.js +910 -0
  40. package/dist/page/client/routines.js.map +1 -0
  41. package/dist/page/client/shell.d.ts +6 -0
  42. package/dist/page/client/shell.js +690 -0
  43. package/dist/page/client/shell.js.map +1 -0
  44. package/dist/page/client/state.d.ts +7 -0
  45. package/dist/page/client/state.js +1275 -0
  46. package/dist/page/client/state.js.map +1 -0
  47. package/dist/page/client/transcript.d.ts +8 -0
  48. package/dist/page/client/transcript.js +699 -0
  49. package/dist/page/client/transcript.js.map +1 -0
  50. package/dist/page/client/usage.d.ts +7 -0
  51. package/dist/page/client/usage.js +954 -0
  52. package/dist/page/client/usage.js.map +1 -0
  53. package/dist/page/style.d.ts +1 -0
  54. package/dist/page/style.js +2793 -0
  55. package/dist/page/style.js.map +1 -0
  56. package/dist/page.js +70 -13583
  57. package/dist/page.js.map +1 -1
  58. package/dist/server.d.ts +24 -0
  59. package/dist/server.js +511 -25
  60. package/dist/server.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,767 @@
1
+ /**
2
+ * The two panes that hold a deliverable: the review pane for changed files,
3
+ * and the artifact shelf with its canvas bridge and publish flow.
4
+ *
5
+ * One part of the client script; see ./index.ts for how the parts join.
6
+ */
7
+ export const ARTIFACTS = String.raw `
8
+ // --- review pane (the Codex-shaped part) ----------------------------------
9
+ // The deliverable of a coding agent is the diff, so changed files get a pane
10
+ // of their own rather than living inside a tool card the reader has to hunt
11
+ // for. Activity collapses; the change stays.
12
+
13
+ const review = document.getElementById("review");
14
+ const filesEl = document.getElementById("files");
15
+ const diffEl = document.getElementById("diff");
16
+ const reviewSum = document.getElementById("review-sum");
17
+ const changesBtn = document.getElementById("changes-btn");
18
+ let changes = [];
19
+ let selectedFile = null;
20
+
21
+ function renderChanges(list) {
22
+ changes = list || [];
23
+ const totalAdd = changes.reduce((n, c) => n + (c.added || 0), 0);
24
+ const totalDel = changes.reduce((n, c) => n + (c.removed || 0), 0);
25
+
26
+ changesBtn.hidden = changes.length === 0;
27
+ changesBtn.replaceChildren();
28
+ if (changes.length) {
29
+ changesBtn.appendChild(el("b", null, changes.length + (changes.length === 1 ? " file" : " files")));
30
+ changesBtn.appendChild(document.createTextNode(" "));
31
+ changesBtn.appendChild(el("span", "plus", "+" + totalAdd));
32
+ changesBtn.appendChild(document.createTextNode(" "));
33
+ changesBtn.appendChild(el("span", "minus", "-" + totalDel));
34
+ }
35
+ reviewSum.textContent = changes.length
36
+ ? changes.length + " files · +" + totalAdd + " -" + totalDel
37
+ : "nothing changed yet";
38
+
39
+ filesEl.replaceChildren();
40
+ for (const change of changes) {
41
+ const row = el("div", "file" + (change.path === selectedFile ? " active" : ""));
42
+ row.appendChild(el("span", "p", change.path));
43
+ if (change.before === undefined) row.appendChild(el("span", "tag", "new"));
44
+ else if (change.after === undefined) row.appendChild(el("span", "tag", "deleted"));
45
+ if (change.note) row.appendChild(el("span", "tag", change.note));
46
+ else {
47
+ row.appendChild(el("span", "n plus", "+" + change.added));
48
+ row.appendChild(el("span", "n minus", "-" + change.removed));
49
+ }
50
+ row.addEventListener("click", () => showDiff(change.path));
51
+ filesEl.appendChild(row);
52
+ }
53
+
54
+ if (changes.length && !changes.some((c) => c.path === selectedFile)) showDiff(changes[0].path);
55
+ else if (!changes.length) diffEl.replaceChildren();
56
+ }
57
+
58
+ function showDiff(file) {
59
+ selectedFile = file;
60
+ const change = changes.find((c) => c.path === file);
61
+ diffEl.replaceChildren();
62
+ if (!change) return;
63
+
64
+ if (change.note) {
65
+ diffEl.appendChild(el("div", "notice", change.note));
66
+ } else {
67
+ diffEl.appendChild(renderDiff(change.before || "", change.after || ""));
68
+ }
69
+ for (const row of filesEl.children) {
70
+ row.classList.toggle("active", row.firstChild.textContent === file);
71
+ }
72
+ }
73
+
74
+ changesBtn.addEventListener("click", () => review.classList.toggle("open"));
75
+
76
+ async function loadChanges() {
77
+ const body = await getJson("/changes");
78
+ renderChanges(body.changes);
79
+ }
80
+
81
+ /**
82
+ * Everything session-scoped that lives beside the transcript.
83
+ *
84
+ * One function rather than a call per panel at each of the four places a
85
+ * session view gets established: the fourth of those already existed before
86
+ * artifacts did, and adding a second panel by hand to each of them is how the
87
+ * fifth site ends up with only one of them refreshed.
88
+ */
89
+ async function loadSidePanels() {
90
+ await loadChanges();
91
+ await loadArtifacts();
92
+ }
93
+
94
+ // --- artifacts -------------------------------------------------------------
95
+
96
+ /**
97
+ * The browser's half of the rendering contract in artifacts/store.ts.
98
+ *
99
+ * Every kind the engine can produce has an entry, and adding a kind without
100
+ * one fails test/artifacts.test.ts rather than showing a blank pane. "frame"
101
+ * means the body has to be executed by the browser and therefore goes through
102
+ * the sandboxed route; "source" means this page can render it into DOM nodes
103
+ * itself, which is always preferable — it is the same reason markdown is built
104
+ * as nodes rather than assigned as markup.
105
+ */
106
+ const ARTIFACT_RENDERING = {
107
+ text: { mode: "source" },
108
+ markdown: { mode: "markdown" },
109
+ code: { mode: "source" },
110
+ vector: { mode: "frame" },
111
+ image: { mode: "frame" },
112
+ page: { mode: "frame" },
113
+ };
114
+
115
+ const shelf = document.getElementById("shelf");
116
+ const shelfBody = document.getElementById("shelf-body");
117
+ let artifacts = [];
118
+ let artifactKey = null;
119
+ let selectedArtifact = null;
120
+ /** The strip currently above the body, so it can be redrawn on its own. */
121
+ let shelfBar = null;
122
+ /** Which version is being viewed, per artifact id. Absent means the latest. */
123
+ const artifactVersion = new Map();
124
+
125
+ function renderArtifacts(list) {
126
+ // No list and no counter: every artifact has a card in the conversation that
127
+ // made it, and that is the index. A second list beside the thing being shown
128
+ // was a row of chrome that repeated what the card already said.
129
+ artifacts = list || [];
130
+
131
+ if (artifacts.length && !artifacts.some((a) => a.id === selectedArtifact)) {
132
+ void showArtifact(artifacts[0].id);
133
+ } else if (!artifacts.length) {
134
+ selectedArtifact = null;
135
+ shelfBody.replaceChildren();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The URL an iframe loads. Built here rather than by the caller because it
141
+ * carries the artifact key, which is a different credential from the page's
142
+ * token and must never be swapped for it by accident.
143
+ */
144
+ function artifactFrameUrl(id, version) {
145
+ const params = new URLSearchParams({ id, k: artifactKey || "" });
146
+ if (activeId) params.set("session", activeId);
147
+ if (version) params.set("version", String(version));
148
+ return "/artifact/view?" + params.toString();
149
+ }
150
+
151
+ /**
152
+ * The mime the engine gives a canvas. Repeated here rather than imported
153
+ * because this file is a string served to a browser, and kept next to
154
+ * kindFromMime so the two stay wrong together or not at all.
155
+ */
156
+ const CANVAS_MIME = "text/canvas+html";
157
+
158
+ /**
159
+ * The page's half of the canvas save protocol.
160
+ *
161
+ * A canvas frame is sandboxed without allow-same-origin and served under
162
+ * "connect-src 'none'", so it cannot call this server itself — which is the
163
+ * point, since what runs in there is model-authored markup. What it can do is
164
+ * postMessage, and this is the only thing listening.
165
+ *
166
+ * Two things make that safe to act on. The frame's origin is opaque, so
167
+ * event.origin is the string 'null' and proves nothing; identity comes from
168
+ * event.source being this exact frame's window instead. And the message
169
+ * carries content, never a destination — the id being revised is the one this
170
+ * page opened, not one the frame names, so a canvas cannot ask to overwrite
171
+ * some other artifact.
172
+ */
173
+ let canvasBridge = null;
174
+
175
+ function detachCanvasBridge() {
176
+ if (!canvasBridge) return;
177
+ window.removeEventListener("message", canvasBridge.handler);
178
+ canvasBridge = null;
179
+ }
180
+
181
+ function attachCanvasBridge(frame, meta, stale) {
182
+ detachCanvasBridge();
183
+
184
+ const tell = (message) => {
185
+ // "*" rather than location.origin: the recipient has an opaque origin, so
186
+ // there is no origin that would match, and the payload is the artifact the
187
+ // frame is already showing.
188
+ if (frame.contentWindow) frame.contentWindow.postMessage(
189
+ Object.assign({ source: "oharness-canvas-host" }, message), "*");
190
+ };
191
+
192
+ const handler = async (event) => {
193
+ if (!canvasBridge || event.source !== frame.contentWindow) return;
194
+ const message = event.data;
195
+ if (!message || message.source !== "oharness-canvas") return;
196
+
197
+ if (message.type === "ready") {
198
+ // Saving an older revision would write it back as the newest one and
199
+ // quietly discard everything since. The editor is told to stay read-only
200
+ // instead, which it shows by having no Save button at all.
201
+ tell({ type: "hello", canSave: !stale, version: meta.version });
202
+ return;
203
+ }
204
+
205
+ if (message.type === "save") {
206
+ if (stale || typeof message.content !== "string") return;
207
+ const path = activeId
208
+ ? "/artifact/edit?session=" + encodeURIComponent(activeId)
209
+ : "/artifact/edit";
210
+ const body = await api(path, { id: meta.id, content: message.content });
211
+ if (body.error) {
212
+ tell({ type: "error", message: body.error });
213
+ addNotice("could not save the canvas: " + body.error, "err");
214
+ return;
215
+ }
216
+ tell({ type: "saved", version: body.version });
217
+ // Nothing else to do: the server broadcasts an artifact event, and the
218
+ // handler for it patches the list and redraws the bar. What it must not
219
+ // do is rebuild the frame — see the author check there.
220
+ }
221
+ };
222
+
223
+ canvasBridge = { frame, handler };
224
+ window.addEventListener("message", handler);
225
+ }
226
+
227
+ /**
228
+ * Redraw the strip above the body without touching the body.
229
+ *
230
+ * The version picker and the publish state both go stale when a revision
231
+ * lands, and the usual answer is to re-run showArtifact — which is right for
232
+ * everything except a canvas being edited, where the body is a live editor and
233
+ * rebuilding it throws away what the user was doing.
234
+ */
235
+ function refreshArtifactBar(meta) {
236
+ // Held as a reference rather than looked up by id: the strip is built in
237
+ // code, so there is no element in the static markup for a lookup to match,
238
+ // and test/web-page.test.ts is right to insist that every id the script
239
+ // reaches for is one the page actually ships.
240
+ if (!shelfBar || !shelfBar.isConnected) return;
241
+ const next = bar(meta, artifactVersion.get(meta.id));
242
+ shelfBar.replaceWith(next);
243
+ shelfBar = next;
244
+ }
245
+
246
+ async function showArtifact(id) {
247
+ selectedArtifact = id;
248
+ const meta = artifacts.find((a) => a.id === id);
249
+ if (!meta) return;
250
+ const wanted = artifactVersion.get(id);
251
+ // Whatever frame was listening is about to be replaced or navigated away
252
+ // from; a listener left behind would answer for a window that is gone.
253
+ detachCanvasBridge();
254
+ const rendering = ARTIFACT_RENDERING[meta.kind] || { mode: "source" };
255
+
256
+ shelfBar = bar(meta, wanted);
257
+ shelfBody.replaceChildren(shelfBar);
258
+
259
+ if (rendering.mode === "frame") {
260
+ const frame = el("iframe");
261
+ frame.id = "shelf-frame";
262
+ // allow-scripts and NOT allow-same-origin. The two together let the frame
263
+ // reach up and strip its own sandbox attribute, which is why every guide
264
+ // calls that combination equivalent to no sandbox at all.
265
+ frame.setAttribute("sandbox", "allow-scripts");
266
+ frame.setAttribute("referrerpolicy", "no-referrer");
267
+ // Deliberately not q(): that appends the session token, and this URL is
268
+ // readable by the document it loads. The whole point of the separate "k"
269
+ // credential is that the agent's token never reaches an artifact.
270
+ frame.src = artifactFrameUrl(id, wanted);
271
+ shelfBody.appendChild(frame);
272
+ // A canvas is a page like any other to the sandbox; the difference is that
273
+ // this one talks back. Attached before the frame can load so the "ready"
274
+ // it sends on startup is not sent into a page that is not listening yet.
275
+ if (String(meta.mime).split(";")[0].trim().toLowerCase() === CANVAS_MIME) {
276
+ attachCanvasBridge(frame, meta, Boolean(wanted) && Number(wanted) !== meta.version);
277
+ }
278
+ return;
279
+ }
280
+
281
+ const path =
282
+ "/artifact/raw?id=" + encodeURIComponent(id) + (wanted ? "&version=" + wanted : "");
283
+ const body = await getJson(activeId ? path + "&session=" + encodeURIComponent(activeId) : path);
284
+ if (body.error) {
285
+ shelfBody.appendChild(el("div", "notice", body.error));
286
+ return;
287
+ }
288
+ if (rendering.mode === "markdown") {
289
+ const host = el("div");
290
+ host.id = "shelf-source";
291
+ host.style.whiteSpace = "normal";
292
+ host.style.fontFamily = "inherit";
293
+ host.appendChild(renderMarkdown(body.content));
294
+ shelfBody.appendChild(host);
295
+ return;
296
+ }
297
+ const pre = el("pre");
298
+ pre.id = "shelf-source";
299
+ // textContent, not innerHTML: this is model-authored content and the whole
300
+ // reason the executable kinds go through an iframe instead of coming here.
301
+ pre.textContent = body.content;
302
+ shelfBody.appendChild(pre);
303
+ }
304
+
305
+ /**
306
+ * A card for an artifact_write found in the transcript.
307
+ *
308
+ * The descriptor a live card is built from does not exist here — there was no
309
+ * event — so the few fields it needs are read back off the call's arguments.
310
+ * Version is deliberately absent rather than guessed: the transcript knows how
311
+ * many times an id was written, but this loop sees one call at a time, and a
312
+ * wrong version number on a card that claims to open that version is worse
313
+ * than no number.
314
+ */
315
+ function replayedArtifactCard(input) {
316
+ if (!input || typeof input !== "object") return null;
317
+ const { id, title, mime, content } = input;
318
+ if (typeof id !== "string" || typeof title !== "string") return null;
319
+ return artifactCard(
320
+ {
321
+ id,
322
+ title,
323
+ mime: typeof mime === "string" ? mime : "text/plain",
324
+ kind: kindFromMime(typeof mime === "string" ? mime : "text/plain"),
325
+ bytes: typeof content === "string" ? new Blob([content]).size : 0,
326
+ version: 1,
327
+ },
328
+ { attach: false },
329
+ );
330
+ }
331
+
332
+ /** The engine's kindForMime, for the handful of types the page has to label. */
333
+ function kindFromMime(mime) {
334
+ const base = String(mime).split(";")[0].trim().toLowerCase();
335
+ if (base === "text/html" || base === CANVAS_MIME) return "page";
336
+ if (base === "image/svg+xml") return "vector";
337
+ if (base === "text/markdown") return "markdown";
338
+ if (base === "application/json") return "code";
339
+ if (base.startsWith("image/")) return "image";
340
+ return "text";
341
+ }
342
+
343
+ function formatBytes(bytes) {
344
+ if (bytes < 1024) return bytes + " B";
345
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
346
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
347
+ }
348
+
349
+ /**
350
+ * The inline card for one artifact event.
351
+ *
352
+ * One per event rather than one per artifact, updated in place: a revision is
353
+ * something that happened at a point in the conversation, and collapsing five
354
+ * of them into a single card that silently changes under the earlier text
355
+ * would make the transcript disagree with itself.
356
+ */
357
+ function artifactCard(artifact, opts) {
358
+ const card = el("div", "art-card");
359
+ card.setAttribute("role", "button");
360
+ card.tabIndex = 0;
361
+
362
+ // A glyph per kind rather than one shape for everything: the card is scanned
363
+ // in a column of near-identical blocks, and the difference between a chart
364
+ // and a page is the first thing worth seeing.
365
+ const mark = el("span", "art-card-mark");
366
+ mark.appendChild(kindIcon(artifact.kind));
367
+ card.appendChild(mark);
368
+
369
+ const text = el("div", "art-card-text");
370
+ text.appendChild(el("div", "art-card-title", artifact.title));
371
+ const sub = artifact.version > 1
372
+ ? artifact.kind + " · v" + artifact.version
373
+ : artifact.kind + " · " + formatBytes(artifact.bytes);
374
+ text.appendChild(el("div", "art-card-sub", sub));
375
+ card.appendChild(text);
376
+
377
+ const openMark = el("span", "art-card-open");
378
+ openMark.title = "Open";
379
+ openMark.appendChild(iconChevron());
380
+ card.appendChild(openMark);
381
+
382
+ // The version this card is about, so clicking an older card shows what it
383
+ // was announcing rather than whatever the artifact has become since.
384
+ const at = artifact.version;
385
+ const show = () => {
386
+ shelf.classList.add("open");
387
+ const latest = artifacts.find((a) => a.id === artifact.id);
388
+ if (latest && at !== latest.version) artifactVersion.set(artifact.id, String(at));
389
+ else artifactVersion.delete(artifact.id);
390
+ void showArtifact(artifact.id);
391
+ };
392
+ card.addEventListener("click", show);
393
+ card.addEventListener("keydown", (event) => {
394
+ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); show(); }
395
+ });
396
+
397
+ if (opts && opts.attach === false) return card;
398
+ assistantTurn().appendChild(card);
399
+ follow();
400
+ return card;
401
+ }
402
+
403
+ /** The strip above a body: version picker and an escape hatch to a real tab. */
404
+ /**
405
+ * The strip above an artifact.
406
+ *
407
+ * Left: what it is — its type, and which revision is on screen. Right: what
408
+ * you can do about it, and in two groups. The glyphs are the cheap, reversible
409
+ * moves — open it elsewhere, make it bigger, put it away. Publishing is the
410
+ * one act with a consequence off this machine, so it is the one thing spelled
411
+ * out, and it sits at the end where a deliberate step belongs.
412
+ */
413
+ function bar(meta, wanted) {
414
+ const row = el("div");
415
+ row.id = "shelf-bar";
416
+ // The title, not the mime type. What this is called is what identifies it;
417
+ // "image/svg+xml" is a detail that was taking the most prominent slot in the
418
+ // panel and repeating what the card in the conversation already showed. It
419
+ // survives as the tooltip, where a detail belongs.
420
+ const name = el("span", "shelf-title", meta.title);
421
+ name.title = meta.mime;
422
+ row.appendChild(name);
423
+
424
+ if (meta.versionCount > 1) {
425
+ // Our own dropdown, not a native <select>: that one draws its popup and
426
+ // its focus ring with the operating system's chrome, which no stylesheet
427
+ // here reaches — the same reason makePicker exists. This borrows the
428
+ // popover the Publish menu uses rather than makePicker itself, which
429
+ // mandates a search box built for a list of a thousand models.
430
+ const shown = wanted ? Number(wanted) : meta.version;
431
+ const pick = el("button", "ver-btn");
432
+ pick.type = "button";
433
+ pick.appendChild(el("span", null, "v" + shown + (shown === meta.version ? " (latest)" : "")));
434
+ pick.appendChild(caret());
435
+ pick.addEventListener("click", () => {
436
+ const oldest = Math.max(1, meta.version - meta.versionCount + 1);
437
+ const choices = [];
438
+ for (let v = meta.version; v >= oldest; v--) {
439
+ choices.push({ label: "v" + v + (v === meta.version ? " (latest)" : ""), version: v });
440
+ }
441
+ showMenuNear(pick, choices, (choice) => {
442
+ // The latest is stored as "no preference", so a later revision does
443
+ // not leave the pane pinned to what is now an old version.
444
+ if (choice.version === meta.version) artifactVersion.delete(meta.id);
445
+ else artifactVersion.set(meta.id, String(choice.version));
446
+ void showArtifact(meta.id);
447
+ });
448
+ });
449
+ row.appendChild(pick);
450
+ }
451
+
452
+ row.appendChild(el("span", "shelf-gap"));
453
+
454
+ // --- the glyphs, as one group -------------------------------------------
455
+ const icons = el("div", "shelf-icons");
456
+
457
+ const open = el("a", "icon-link");
458
+ open.title = "Open in a new tab";
459
+ open.setAttribute("aria-label", "Open in a new tab");
460
+ open.appendChild(iconExternal());
461
+ // Deliberately not q(): that appends the session token, and this URL is
462
+ // readable by the document it loads.
463
+ open.href = artifactFrameUrl(meta.id, wanted);
464
+ open.target = "_blank";
465
+ open.rel = "noopener noreferrer";
466
+ icons.appendChild(open);
467
+
468
+ const expand = el("button", "pane-btn");
469
+ expand.type = "button";
470
+ const paintExpand = () => {
471
+ const full = shelf.classList.contains("full");
472
+ expand.replaceChildren(full ? iconCollapse() : iconExpand());
473
+ expand.title = full ? "Leave full screen" : "Full screen";
474
+ };
475
+ expand.addEventListener("click", () => {
476
+ shelf.classList.toggle("full");
477
+ document.body.classList.toggle("shelf-full", shelf.classList.contains("full"));
478
+ paintExpand();
479
+ });
480
+ paintExpand();
481
+ icons.appendChild(expand);
482
+
483
+ const close = el("button", "pane-btn");
484
+ close.type = "button";
485
+ close.title = "Close";
486
+ close.appendChild(iconClose());
487
+ close.addEventListener("click", () => {
488
+ // Leaving it full screen would hide the conversation the next time it is
489
+ // opened, with no obvious way back.
490
+ shelf.classList.remove("open", "full");
491
+ document.body.classList.remove("shelf-full");
492
+ });
493
+ icons.appendChild(close);
494
+ row.appendChild(icons);
495
+
496
+ // --- sharing, last ------------------------------------------------------
497
+ const pub = meta.publication;
498
+ if (pub) {
499
+ // A link serves the revision that was published, not the latest one. Said
500
+ // out loud, because the alternative is the author believing they shared
501
+ // something they did not.
502
+ const stale = pub.version !== meta.version;
503
+ if (stale) row.appendChild(el("span", "stale", "link shows v" + pub.version));
504
+
505
+ const copy = el("button", "share-btn", "copy link");
506
+ copy.type = "button";
507
+ copy.addEventListener("click", () => void copyLink(pub.url, copy));
508
+ row.appendChild(copy);
509
+
510
+ if (stale) {
511
+ const update = el("button", "share-btn", "update link");
512
+ update.type = "button";
513
+ update.addEventListener("click", () =>
514
+ void publish(meta, update, pub.visibility, pub.teamId));
515
+ row.appendChild(update);
516
+ }
517
+
518
+ const withdraw = el("button", "share-btn", "withdraw");
519
+ withdraw.type = "button";
520
+ withdraw.addEventListener("click", () => void unpublish(meta, withdraw));
521
+ row.appendChild(withdraw);
522
+ } else {
523
+ // Icon and word both. The ellipsis this used to carry was the "opens a
524
+ // chooser" convention, but at this size it read as a truncated label —
525
+ // which is a worse thing to say than nothing.
526
+ const share = el("button", "share-btn primary-action");
527
+ share.type = "button";
528
+ share.appendChild(iconShare());
529
+ share.appendChild(el("span", null, "Publish"));
530
+ share.addEventListener("click", () => void offerPublish(meta, share));
531
+ row.appendChild(share);
532
+ }
533
+
534
+ return row;
535
+ }
536
+
537
+ /** The three glyphs the panel controls use, drawn to match the SVG set. */
538
+ function paneIcon(d) {
539
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
540
+ svg.setAttribute("viewBox", "0 0 16 16");
541
+ svg.setAttribute("fill", "none");
542
+ svg.setAttribute("stroke", "currentColor");
543
+ svg.setAttribute("stroke-width", "1.5");
544
+ svg.setAttribute("stroke-linecap", "round");
545
+ svg.setAttribute("stroke-linejoin", "round");
546
+ svg.setAttribute("aria-hidden", "true");
547
+ for (const segment of d) {
548
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
549
+ path.setAttribute("d", segment);
550
+ svg.appendChild(path);
551
+ }
552
+ return svg;
553
+ }
554
+
555
+ /*
556
+ * Four corner brackets, out then in.
557
+ *
558
+ * The first attempt drew arrows with diagonals across the middle, which at
559
+ * this size collapsed into a smudge and made "leave full screen" unreadable —
560
+ * two marks that differ only by the direction of a 4px line are not two marks.
561
+ * Brackets differ by which way the corner opens, which survives being small.
562
+ */
563
+ const iconExpand = () =>
564
+ paneIcon(["M6 3H3v3", "M10 3h3v3", "M6 13H3v-3", "M10 13h3v-3"]);
565
+ const iconCollapse = () =>
566
+ paneIcon(["M3 6h3V3", "M13 6h-3V3", "M3 10h3v3", "M13 10h-3v3"]);
567
+ const iconClose = () => paneIcon(["M4.5 4.5 11.5 11.5", "M11.5 4.5 4.5 11.5"]);
568
+ /* An upload, because that is what publishing is: a copy leaves this machine. */
569
+ const iconShare = () => paneIcon(["M8 10.5V3", "M5 6 8 3l3 3", "M3.5 10v2.5h9V10"]);
570
+ const iconExternal = () =>
571
+ paneIcon(["M7 3.5H3.5v9h9V9", "M9.5 3h3.5v3.5", "M13 3 8 8"]);
572
+ const iconChevron = () => paneIcon(["M6.5 3.5 11 8l-4.5 4.5"]);
573
+
574
+ /**
575
+ * The glyph for each artifact kind.
576
+ *
577
+ * Every entry in ARTIFACT_KINDS has one, for the same reason the rendering
578
+ * tables do: a kind added to the engine without a shape here falls back to a
579
+ * generic box rather than drawing nothing, and the fallback is visible enough
580
+ * that somebody notices.
581
+ */
582
+ function kindIcon(kind) {
583
+ switch (kind) {
584
+ case "page":
585
+ return paneIcon(["M2.5 3.5h11v9h-11z", "M2.5 6.5h11", "M4.5 5V5"]);
586
+ case "vector":
587
+ return paneIcon(["M2.5 12.5V3.5", "M2.5 12.5h11", "M5 10.5v-3", "M8 10.5v-5", "M11 10.5v-2"]);
588
+ case "image":
589
+ return paneIcon(["M2.5 3.5h11v9h-11z", "M2.5 10 6 7l3 2.5L11 8l2.5 2"]);
590
+ case "markdown":
591
+ return paneIcon(["M3.5 2.5h6l3 3v8h-9z", "M9.5 2.5v3h3", "M5.5 8.5h5", "M5.5 10.5h3"]);
592
+ case "code":
593
+ return paneIcon(["M6 5.5 3 8l3 2.5", "M10 5.5 13 8l-3 2.5"]);
594
+ case "text":
595
+ return paneIcon(["M3.5 2.5h6l3 3v8h-9z", "M9.5 2.5v3h3"]);
596
+ default:
597
+ return paneIcon(["M2.5 3.5h11v9h-11z"]);
598
+ }
599
+ }
600
+
601
+
602
+ /**
603
+ * A small chooser anchored to a button.
604
+ *
605
+ * Positioned from the viewport rather than from an offset parent: the button
606
+ * lives inside a panel that scrolls and clips, and an absolutely positioned
607
+ * menu inside it is either cut off or scrolls away from what it belongs to.
608
+ */
609
+ function showMenuNear(anchor, choices, onPick) {
610
+ const menu = el("div", "menu pop");
611
+ const close = () => {
612
+ menu.remove();
613
+ document.removeEventListener("mousedown", onAway, true);
614
+ document.removeEventListener("keydown", onKey, true);
615
+ };
616
+ const onAway = (event) => { if (!menu.contains(event.target)) close(); };
617
+ const onKey = (event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } };
618
+
619
+ for (const choice of choices) {
620
+ const item = el("button", "menu-item");
621
+ item.type = "button";
622
+ item.appendChild(el("span", "menu-label", choice.label));
623
+ item.addEventListener("click", () => { close(); onPick(choice); });
624
+ menu.appendChild(item);
625
+ }
626
+
627
+ document.body.appendChild(menu);
628
+ const box = anchor.getBoundingClientRect();
629
+ // Flip above when there is no room below, so the last team in a long list is
630
+ // not off the bottom of the window.
631
+ const below = window.innerHeight - box.bottom;
632
+ const height = menu.getBoundingClientRect().height;
633
+ menu.style.top = (below < height + 12 ? Math.max(8, box.top - height - 6) : box.bottom + 6) + "px";
634
+ menu.style.left = Math.max(8, Math.min(box.left, window.innerWidth - menu.offsetWidth - 8)) + "px";
635
+
636
+ document.addEventListener("mousedown", onAway, true);
637
+ document.addEventListener("keydown", onKey, true);
638
+ }
639
+
640
+ /** The chevron the composer's pickers draw, so this one is not a different shape. */
641
+ function caret() {
642
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
643
+ svg.setAttribute("class", "caret");
644
+ svg.setAttribute("viewBox", "0 0 16 16");
645
+ svg.setAttribute("fill", "none");
646
+ svg.setAttribute("stroke", "currentColor");
647
+ svg.setAttribute("stroke-width", "1.5");
648
+ svg.setAttribute("stroke-linecap", "round");
649
+ svg.setAttribute("stroke-linejoin", "round");
650
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
651
+ path.setAttribute("d", "M4.5 6.5 8 10l3.5-3.5");
652
+ svg.appendChild(path);
653
+ return svg;
654
+ }
655
+
656
+ async function copyLink(url, button) {
657
+ await navigator.clipboard.writeText(url).catch(() => {});
658
+ button.textContent = "copied";
659
+ setTimeout(() => { button.textContent = "copy link"; }, 1500);
660
+ }
661
+
662
+ /**
663
+ * The teams the account is in, fetched once.
664
+ *
665
+ * Null while unknown, [] when signed out or when there are none — both of
666
+ * which mean the same thing to the menu below, which is why the failure path
667
+ * in the engine answers with a list rather than an error.
668
+ */
669
+ let accountTeams = null;
670
+
671
+ /**
672
+ * Publishing asks first when there is a choice to make.
673
+ *
674
+ * With no teams there is only one thing Publish can mean, and a menu offering
675
+ * one option is a click for nothing.
676
+ */
677
+ async function offerPublish(meta, button) {
678
+ if (accountTeams === null) {
679
+ button.disabled = true;
680
+ // getJson, not api(): api() posts, and this route is a GET. Sending the
681
+ // wrong verb answered 404, which this function reads as "no teams" — so
682
+ // the menu silently never appeared and every publish went out unlisted.
683
+ const body = await getJson("/teams");
684
+ button.disabled = false;
685
+ accountTeams = body.error ? [] : (body.teams || []);
686
+ }
687
+ if (accountTeams.length === 0) {
688
+ void publish(meta, button, "unlisted");
689
+ return;
690
+ }
691
+
692
+ const choices = [{ label: "Anyone with the link", visibility: "unlisted" }].concat(
693
+ accountTeams.map((team) => ({
694
+ label: team.name + " (team only)",
695
+ visibility: "team",
696
+ teamId: team.id,
697
+ })),
698
+ );
699
+ showMenuNear(button, choices, (choice) =>
700
+ void publish(meta, button, choice.visibility, choice.teamId));
701
+ }
702
+
703
+ async function publish(meta, button, visibility, teamId) {
704
+ const was = button.textContent;
705
+ button.disabled = true;
706
+ button.textContent = "publishing…";
707
+ // The site is a real network hop away and may be signing a token on the way
708
+ // through, so this gets longer than the default before it gives up.
709
+ const body = await api(
710
+ "/artifact/publish",
711
+ { id: meta.id, visibility: visibility || "unlisted", teamId },
712
+ 45000,
713
+ );
714
+ button.disabled = false;
715
+
716
+ if (body.error) {
717
+ button.textContent = was;
718
+ // Not signed in, offline, over the limit — all actionable, so the reason
719
+ // is shown rather than swallowed into a failed state with no explanation.
720
+ addNotice("could not publish: " + body.error, "err");
721
+ return;
722
+ }
723
+
724
+ // The row is redrawn from the artifact event the server broadcasts, so the
725
+ // button this click was on is about to be replaced. Only the clipboard and
726
+ // the notice are this function's to do.
727
+ await navigator.clipboard.writeText(body.url).catch(() => {});
728
+ addNotice((body.updated ? "updated " : "published ") + body.url);
729
+ }
730
+
731
+ async function unpublish(meta, button) {
732
+ button.disabled = true;
733
+ button.textContent = "withdrawing…";
734
+ const body = await api("/artifact/unpublish", { id: meta.id }, 30000);
735
+ button.disabled = false;
736
+ if (body.error) {
737
+ button.textContent = "withdraw";
738
+ addNotice("could not withdraw: " + body.error, "err");
739
+ return;
740
+ }
741
+ addNotice("withdrawn — the link no longer resolves");
742
+ }
743
+
744
+ async function loadArtifacts() {
745
+ const path = activeId ? "/artifacts?session=" + encodeURIComponent(activeId) : "/artifacts";
746
+ const body = await getJson(path);
747
+ if (body.error) return;
748
+ artifactKey = body.viewKey || artifactKey;
749
+ renderArtifacts(body.artifacts);
750
+ // Here rather than only on artifact events: opening a session replays the
751
+ // writes in its transcript, so artifacts can appear without an event ever
752
+ // being broadcast, and the sidebar would go on reading zero.
753
+ await refreshArtifactCount();
754
+ }
755
+
756
+
757
+
758
+ /**
759
+ * Providers and their models.
760
+ *
761
+ * The built-in catalog carries provider facts — endpoint, environment
762
+ * variables, protocol — because those are stable. Model ids are not: vendors
763
+ * rename them faster than a package ships, so the only list that can be right
764
+ * is the one the vendor answers with. Hence a refresh per provider rather than
765
+ * a bundled roster that quietly goes stale.
766
+ */`;
767
+ //# sourceMappingURL=artifacts.js.map