@oharness/server 0.7.1 → 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.
- package/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +132 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +62 -13871
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +349 -22
- package/dist/server.js.map +1 -1
- 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
|