@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,1694 @@
1
+ /**
2
+ * The composer and everything reachable from it: staged attachments, the +
3
+ * menu, connectors and plugins, the caret mirror, slash commands and @
4
+ * mentions, and the field's own height.
5
+ *
6
+ * One part of the client script; see ./index.ts for how the parts join.
7
+ */
8
+ export const COMPOSER = String.raw `
9
+ // --- attachments -----------------------------------------------------------
10
+ // Files are staged on the server and referenced by id, so the prompt request
11
+ // stays small and a half-finished upload cannot start a turn.
12
+
13
+ const attachEl = document.getElementById("attachments");
14
+ const queuedEl = document.getElementById("queued");
15
+ const fileInput = document.getElementById("file");
16
+ const composerBox = document.getElementById("composer-box");
17
+ let staged = [];
18
+ /**
19
+ * Previews of images this tab just sent, keyed by session.
20
+ *
21
+ * Held only until the prompt event for that turn comes back, which is the
22
+ * moment the bubble is drawn. The object URLs are the ones the chips used, so
23
+ * showing them costs no request and no second copy.
24
+ */
25
+ const sentPreviews = new Map();
26
+ /**
27
+ * What this message points at: files and folders in the project, MCP
28
+ * connectors, plugins. Nothing is copied — the chip carries the name, and the
29
+ * server turns the list into a short block above the prompt.
30
+ */
31
+ let refs = [];
32
+ const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
33
+ const refKey = (ref) => ref.kind + ":" + ref.value;
34
+
35
+ /**
36
+ * The messages waiting behind the running turn.
37
+ *
38
+ * Drawn from the pane rather than from a local list: the queue lives on the
39
+ * server, so a reload and a second tab show the same thing, and an entry that
40
+ * has just been sent disappears because the server said so.
41
+ */
42
+ function renderQueued() {
43
+ queuedEl.replaceChildren();
44
+ // The active pane, not the event-scoped P: this row sits in the composer,
45
+ // and the composer belongs to the tab on screen even while a background
46
+ // tab is the one streaming.
47
+ const here = panes.get(activeId);
48
+ const waiting = (here && here.queued) || [];
49
+ for (const entry of waiting) {
50
+ const row = el("div", "q" + (entry.soon ? " soon" : ""));
51
+ // Two different promises: one joins the turn that is running, the other
52
+ // waits for it to end. Saying "Queued" for both would misdescribe half.
53
+ row.appendChild(el("span", "tag", t(entry.soon ? "queue.soon" : "queue.mark")));
54
+ const names = (entry.attachments || []).join(", ");
55
+ row.appendChild(el("span", "n", entry.text || names || t("queue.attachmentsOnly")));
56
+ // Only on the ones that are waiting. A message already handed to the
57
+ // running turn is as immediate as it gets, and a second button promising
58
+ // to hurry it would be promising nothing.
59
+ if (!entry.soon) {
60
+ // Words, not an arrow. An arrow beside a × on a row that is already
61
+ // about sending reads as "next" or "move"; the one thing it has to say
62
+ // is that this message goes now.
63
+ const now = el("button", "go", t("queue.sendNow"));
64
+ now.type = "button";
65
+ now.addEventListener("click", () => void api("/sendnow", { session: activeId, id: entry.id }));
66
+ row.appendChild(now);
67
+ }
68
+ const drop = el("button", "x", "×");
69
+ drop.type = "button";
70
+ drop.title = t("queue.drop");
71
+ drop.addEventListener("click", () => void api("/unqueue", { session: activeId, id: entry.id }));
72
+ row.appendChild(drop);
73
+ queuedEl.appendChild(row);
74
+ }
75
+ // Idle with something still waiting means the turn was stopped or failed —
76
+ // the server will not restart a queue on its own after either. The send
77
+ // button becomes the way to let it go.
78
+ sendBtn.classList.toggle("has-queue", waiting.length > 0 && !(here && here.busy));
79
+ }
80
+
81
+ function renderAttachments() {
82
+ attachEl.replaceChildren();
83
+
84
+ // References first: they were picked deliberately from the menu, while an
85
+ // upload is often a screenshot that was pasted without being thought about.
86
+ for (const ref of refs) {
87
+ const chip = el("div", "att");
88
+ chip.appendChild(svgIcon(REF_ICON[ref.kind]));
89
+ chip.appendChild(el("span", "n", ref.label));
90
+ if (ref.sub) chip.appendChild(el("span", "where", ref.sub));
91
+ const drop = el("button", "x", "×");
92
+ drop.type = "button";
93
+ drop.title = t("add.remove");
94
+ drop.addEventListener("click", () => {
95
+ refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
96
+ renderAttachments();
97
+ });
98
+ chip.appendChild(drop);
99
+ attachEl.appendChild(chip);
100
+ }
101
+
102
+ for (const file of staged) {
103
+ const chip = el("div", "att");
104
+ if (file.preview) {
105
+ const img = document.createElement("img");
106
+ img.src = file.preview;
107
+ img.alt = "";
108
+ chip.appendChild(img);
109
+ }
110
+ chip.appendChild(el("span", "n", file.name));
111
+ chip.appendChild(el("span", "sz", (file.bytes / 1024).toFixed(0) + "KB"));
112
+ const remove = el("button", "x", "×");
113
+ remove.type = "button";
114
+ remove.addEventListener("click", () => {
115
+ staged = staged.filter((entry) => entry.id !== file.id);
116
+ // The server is holding these bytes for a prompt that is no longer
117
+ // going to name them, and it has no other way to learn that.
118
+ void api("/upload/drop", { id: file.id });
119
+ if (file.preview) URL.revokeObjectURL(file.preview);
120
+ renderAttachments();
121
+ });
122
+ chip.appendChild(remove);
123
+ attachEl.appendChild(chip);
124
+ }
125
+ }
126
+
127
+ async function upload(file) {
128
+ const response = await fetch(q("/upload?session=" + encodeURIComponent(activeId)), {
129
+ method: "POST",
130
+ headers: { "content-type": file.type || "application/octet-stream", "x-filename": file.name },
131
+ body: file,
132
+ });
133
+ const body = await response.json().catch(() => ({}));
134
+ if (!response.ok) return void addNotice(body.error || "upload failed", "err");
135
+
136
+ // A local preview rather than a round trip: the bytes are already here.
137
+ if (body.kind === "image") body.preview = URL.createObjectURL(file);
138
+ staged.push(body);
139
+ renderAttachments();
140
+ }
141
+
142
+ fileInput.addEventListener("change", async () => {
143
+ for (const file of fileInput.files) await upload(file);
144
+ fileInput.value = "";
145
+ });
146
+
147
+ for (const type of ["dragenter", "dragover"]) {
148
+ composerBox.addEventListener(type, (e) => {
149
+ e.preventDefault();
150
+ composerBox.classList.add("drag");
151
+ });
152
+ }
153
+ for (const type of ["dragleave", "drop"]) {
154
+ composerBox.addEventListener(type, (e) => {
155
+ e.preventDefault();
156
+ composerBox.classList.remove("drag");
157
+ });
158
+ }
159
+ composerBox.addEventListener("drop", async (e) => {
160
+ for (const file of e.dataTransfer.files) await upload(file);
161
+ });
162
+
163
+ input.addEventListener("paste", async (e) => {
164
+ // A pasted screenshot is the common case; without this it lands as nothing.
165
+ for (const item of e.clipboardData.items) {
166
+ if (item.kind !== "file") continue;
167
+ const file = item.getAsFile();
168
+ if (file) {
169
+ e.preventDefault();
170
+ await upload(file);
171
+ }
172
+ }
173
+ });
174
+
175
+ // --- the + menu, and @ mentions --------------------------------------------
176
+ /**
177
+ * Two ways to point a message at something, split by where the something is.
178
+ *
179
+ * The + reaches this machine: a file or a folder chosen from the desktop, or
180
+ * an upload, plus the connectors and plugins the harness has. The dialog is
181
+ * the platform's own and it opens on the machine the agent runs on, which is
182
+ * the only machine whose absolute paths mean anything to it — the browser's
183
+ * own file input deliberately withholds the path, which is right for a pasted
184
+ * screenshot and useless for a log the agent could simply open.
185
+ *
186
+ * Typing @ reaches the project: the same fuzzy search the tree deserves, run
187
+ * from the caret, landing the path in the sentence at the point it was meant
188
+ * for. Codex and Claude Code both put file mentions there, and that is where
189
+ * they belong — "compare @a with @b" is a sentence, and two chips above the
190
+ * box are not. It is also why the + does not carry a project browser: the
191
+ * files someone names most are in the project, and @ already names them.
192
+ */
193
+
194
+ const attachPicker = document.getElementById("attach-picker");
195
+ const attachMenu = attachPicker.querySelector(".menu");
196
+ const attachBody = el("div", "menu-body");
197
+
198
+ // Focusable, so the arrow keys have somewhere to land: this menu has no search
199
+ // box of its own, and the mention list is driven from the textarea.
200
+ attachMenu.tabIndex = -1;
201
+ attachMenu.replaceChildren(attachBody);
202
+
203
+ /** Which screen the menu is on: root, connectors, plugins, or mention. */
204
+ let attachView = "root";
205
+ /** The last answer from /files, for the mention list. */
206
+ let fileList = { entries: [] };
207
+ /** Whether the context endpoint has answered once, for the mention list. */
208
+ let offerLoaded = false;
209
+ /** Connectors and plugins, refreshed each time the menu opens. */
210
+ let offer = { connectors: [], plugins: [], skills: [] };
211
+ /** The connectors on offer rather than the ones already here. */
212
+ let market = [];
213
+ /** The connector whose sign-in is open in a browser tab, if any. */
214
+ let authorizing = null;
215
+ /** The connector being added right now, so its row can say so. */
216
+ let adding = null;
217
+ let attachQuery = "";
218
+ let attachCursor = 0;
219
+ /** Guards against a slow answer to an old keystroke landing after a new one. */
220
+ let attachSeq = 0;
221
+ let attachTimer = null;
222
+ /** Where the @ that opened a mention starts in the textarea; -1 when none. */
223
+ let mentionAt = -1;
224
+
225
+ const folderOf = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
226
+ const nameOf = (path) => (path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path);
227
+
228
+ function referenced(ref) {
229
+ return refs.some((entry) => refKey(entry) === refKey(ref));
230
+ }
231
+
232
+ function toggleRef(ref) {
233
+ if (referenced(ref)) refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
234
+ else refs.push(ref);
235
+ renderAttachments();
236
+ paintAttachMenu();
237
+ }
238
+
239
+ function addRef(kind, value) {
240
+ const ref = { kind: kind, value: value, label: nameOf(value), sub: folderOf(value) };
241
+ if (!referenced(ref)) refs.push(ref);
242
+ renderAttachments();
243
+ }
244
+
245
+ /** The rows on screen, in the order they are painted — what a key can land on. */
246
+ function attachRows() {
247
+ /**
248
+ * What this project can reach, three or four deep, over the full catalogue.
249
+ *
250
+ * The menu used to hand every one of these straight to a dialog, which is
251
+ * the right home for eighty skills and the wrong one for the two somebody
252
+ * actually attaches. So the menu answers the common question itself — what
253
+ * is here, attach one — and the dialog keeps the uncommon ones: read all of
254
+ * them, install something new, take something off.
255
+ */
256
+ if (attachView === "skills" || attachView === "plugins" || attachView === "connectors") {
257
+ const kind = attachView;
258
+ const items =
259
+ kind === "skills" ? offer.skills || []
260
+ : kind === "plugins" ? offer.plugins || []
261
+ : offer.connectors || [];
262
+ const row = (entry) => ({
263
+ type: "offer-ref",
264
+ icon: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "plug",
265
+ // A connector is known by its vendor's mark, the same one the dialog
266
+ // draws; a plug in front of three names is three rows told apart by
267
+ // reading.
268
+ mark: kind === "connectors" ? entry.name : undefined,
269
+ label: entry.title || entry.name,
270
+ note: entry.description || "",
271
+ ref: {
272
+ kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
273
+ value: entry.name,
274
+ label: entry.name,
275
+ },
276
+ });
277
+ /**
278
+ * One list, with the project's own first.
279
+ *
280
+ * A skill checked into the repository is likelier to be the one wanted
281
+ * here, so it is not left to sort alphabetically among eighty that happen
282
+ * to be installed. That is an ordering, not a division: both are usable in
283
+ * this session and a heading between them would be drawing a line where
284
+ * the user does not have to act on one.
285
+ */
286
+ const local = kind === "connectors" ? [] : items.filter((entry) => entry.source === "project");
287
+ const shown = [...local, ...items.filter((entry) => !local.includes(entry))].slice(0, 6);
288
+ const rows = shown.map(row);
289
+ const empty = { skills: "add.noSkills", plugins: "add.noPlugins", connectors: "add.noConnectors" };
290
+ const hidden = items.length - shown.length;
291
+ return [
292
+ { type: "up", icon: "chevronLeft", label: t("add.back") },
293
+ ...rows,
294
+ ...(!items.length ? [{ type: "hint", label: t(empty[kind]) }] : []),
295
+ ...(hidden > 0 ? [{ type: "hint", label: t("add.andMore", { n: hidden }) }] : []),
296
+ // Browse is only a different question where there is somewhere to browse:
297
+ // connectors have a registry of ones not installed, skills and plugins
298
+ // are whatever is on this machine already. "More" only when the menu
299
+ // is holding some back; with everything in view it is the catalogue.
300
+ ...(kind === "connectors"
301
+ ? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll") }]
302
+ : []),
303
+ { type: "manage", kind, icon: "settings", label: t("add.manage") },
304
+ ];
305
+ }
306
+
307
+ if (attachView === "command") {
308
+ return COMMANDS.filter((one) => !attachQuery || one.name.startsWith(attachQuery)).map((one) => ({
309
+ type: "command",
310
+ icon: "command",
311
+ label: "/" + one.name + (one.arg ? " <" + one.arg + ">" : ""),
312
+ note: one.hint(),
313
+ command: one.name,
314
+ }));
315
+ }
316
+ if (attachView === "mention") {
317
+ /**
318
+ * Skills first, then files.
319
+ *
320
+ * The @ began as a way to name a file and files are still most of what it
321
+ * names, but a skill is the other thing worth pointing at from inside a
322
+ * sentence — and the only one where naming it does something: the model is
323
+ * told which skills exist, so naming one asks for it directly instead of
324
+ * leaving the choice to whether the task happened to match a description.
325
+ * There are a handful of them against thousands of files, so they go above
326
+ * rather than competing on rank.
327
+ */
328
+ const query = attachQuery.toLowerCase();
329
+ /**
330
+ * A skill matches on what it is for, not only on what it is called.
331
+ *
332
+ * Names are terse and chosen by whoever wrote the skill; the sentence
333
+ * under the name is the one a person would think of. Someone after the
334
+ * video tools types "video", and only the description contains it.
335
+ */
336
+ const skills = (offer.skills || [])
337
+ .filter((skill) =>
338
+ !query ||
339
+ skill.name.toLowerCase().includes(query) ||
340
+ (skill.description || "").toLowerCase().includes(query),
341
+ )
342
+ /**
343
+ * A few, not all of them.
344
+ *
345
+ * This list is mostly files and should stay that way: eighty skills
346
+ * answering a one-letter query push every file below a fold nobody
347
+ * scrolls, and @ was a way to name a file first. Four, because a skill
348
+ * row is two lines — six of them filled the menu exactly and the files
349
+ * began again just past its bottom edge, which is the same problem with
350
+ * a smaller number on it. The query narrows them as fast as anyone
351
+ * types, and the whole catalogue is a click away under the + menu.
352
+ */
353
+ .slice(0, 4)
354
+ .map((skill) => ({
355
+ type: "skill",
356
+ icon: "skill",
357
+ label: skill.name,
358
+ // Under the name rather than beside it. A file's sub is a folder and
359
+ // fits the right-hand slot; a skill's is the sentence it is chosen on,
360
+ // and in that slot it pushes the name it belongs to off the row.
361
+ note: skill.description || "",
362
+ ref: { kind: "skill", value: skill.name, label: skill.name },
363
+ }));
364
+ /**
365
+ * Plugins, on the same terms as skills and after them.
366
+ *
367
+ * A plugin is a bundle rather than a thing the model loads, so naming one
368
+ * is a weaker instruction than naming a skill — "use the ottoport plugin"
369
+ * steers which tools get reached for rather than pulling in a page of
370
+ * instructions. Worth offering, not worth offering first.
371
+ */
372
+ const plugins = (offer.plugins || [])
373
+ .filter((plugin) =>
374
+ !query ||
375
+ plugin.name.toLowerCase().includes(query) ||
376
+ (plugin.title || "").toLowerCase().includes(query) ||
377
+ (plugin.description || "").toLowerCase().includes(query),
378
+ )
379
+ .slice(0, 4)
380
+ .map((plugin) => ({
381
+ type: "plugin",
382
+ icon: "plugin",
383
+ label: plugin.title || plugin.name,
384
+ note: plugin.description || "",
385
+ ref: { kind: "plugin", value: plugin.name, label: plugin.name },
386
+ }));
387
+ const files = (fileList.entries || []).map((entry) => ({
388
+ type: entry.dir ? "folder" : "file",
389
+ icon: entry.dir ? "folder" : "file",
390
+ label: entry.name,
391
+ sub: folderOf(entry.path),
392
+ ref: { kind: entry.dir ? "folder" : "file", value: entry.path, label: entry.name },
393
+ }));
394
+ /**
395
+ * Headed, but only where a heading distinguishes something.
396
+ *
397
+ * Two kinds of thing share this list and they are not interchangeable —
398
+ * one is a file to read, the other is a way of working the model loads.
399
+ * A heading each says so. With only one kind present the heading is a
400
+ * label on a list of one kind, which is noise.
401
+ */
402
+ const sections = [
403
+ [t("add.skills"), skills],
404
+ [t("add.plugins"), plugins],
405
+ [t("mention.files"), files],
406
+ ].filter(([, rows]) => rows.length);
407
+ // One kind present is a list of one kind, and a heading over it labels
408
+ // nothing. Headings appear when there is something to tell apart.
409
+ if (sections.length < 2) return sections.flatMap(([, rows]) => rows);
410
+ return sections.flatMap(([label, rows]) => [{ type: "group", label }, ...rows]);
411
+ }
412
+
413
+ const connectors = (offer.connectors || []).length;
414
+ const plugins = (offer.plugins || []).length;
415
+ const skills = (offer.skills || []).length;
416
+ return [
417
+ // Two rows, because the dialogs behind them are two: only macOS has a
418
+ // panel that takes a file and a folder in one pass, and everywhere else a
419
+ // single row promising both could open only one of them. Saying which one
420
+ // opens before it opens is also how someone reaching for a screenshot
421
+ // stops reading at the first row.
422
+ { type: "pick", kind: "files", icon: "file", label: t("add.attachFiles") },
423
+ { type: "pick", kind: "folder", icon: "folder", label: t("add.attachFolders") },
424
+ // A count when there is one, and nothing when there is not: "none" is a
425
+ // word in the slot that otherwise holds a number, and it reads as a value
426
+ // rather than as an absence.
427
+ {
428
+ type: "into",
429
+ kind: "connectors",
430
+ icon: "plug",
431
+ label: t("add.connectors"),
432
+ sub: connectors ? String(connectors) : "",
433
+ into: true,
434
+ },
435
+ {
436
+ type: "into",
437
+ kind: "plugins",
438
+ icon: "plugin",
439
+ label: t("add.plugins"),
440
+ sub: plugins ? String(plugins) : "",
441
+ into: true,
442
+ },
443
+ {
444
+ type: "into",
445
+ kind: "skills",
446
+ icon: "skill",
447
+ label: t("add.skills"),
448
+ sub: skills ? String(skills) : "",
449
+ into: true,
450
+ },
451
+ { type: "upload", icon: "upload", label: t("add.upload") },
452
+ ];
453
+ }
454
+
455
+ function paintAttachMenu() {
456
+ const rows = attachRows();
457
+ if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
458
+ // The list opens with the cursor at 0, and 0 is a heading whenever the list
459
+ // is grouped. Nudged here rather than at every call site that sets it.
460
+ while (rows[attachCursor] && rows[attachCursor].type === "group" && attachCursor < rows.length - 1) {
461
+ attachCursor++;
462
+ }
463
+ attachBody.replaceChildren();
464
+
465
+ rows.forEach((row, i) => {
466
+ if (row.type === "group") {
467
+ attachBody.appendChild(el("div", "menu-head", row.label));
468
+ return;
469
+ }
470
+ if (row.type === "hint") {
471
+ attachBody.appendChild(el("div", "menu-hint", row.label));
472
+ return;
473
+ }
474
+ const item = el(
475
+ "div",
476
+ "menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
477
+ );
478
+ item.setAttribute("role", "button");
479
+ item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
480
+ if (row.note) {
481
+ item.classList.add("stacked");
482
+ const stack = el("span", "stack");
483
+ stack.appendChild(el("span", "menu-label", row.label));
484
+ stack.appendChild(el("span", "note", row.note));
485
+ item.appendChild(stack);
486
+ } else {
487
+ item.appendChild(el("span", "menu-label", row.label));
488
+ }
489
+ if (row.sub) item.appendChild(el("span", "sub", row.sub));
490
+ // At the end rather than the start. A tick that is invisible until a row
491
+ // is chosen still held a column open in front of every row that was not,
492
+ // which put the icons a third of the way into the menu and the words
493
+ // further still. On the right it costs nothing until there is one.
494
+ if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
495
+ if (row.into) item.appendChild(el("span", "chev"));
496
+ // An installed connector is removed from the row that says it is
497
+ // installed, which is the row someone looking to remove it goes to.
498
+ if (row.type === "offer" && row.installed) {
499
+ const drop = el("button", "row-drop", "×");
500
+ drop.type = "button";
501
+ drop.title = t("add.remove");
502
+ drop.addEventListener("click", (e) => {
503
+ e.stopPropagation();
504
+ void installConnector(row.id, true);
505
+ });
506
+ item.appendChild(drop);
507
+ }
508
+ item.addEventListener("click", () => chooseAttachRow(row));
509
+ attachBody.appendChild(item);
510
+ });
511
+
512
+ // One line saying why a screen is empty, rather than an empty screen.
513
+ const listed = rows.filter((row) => row.type !== "up").length;
514
+ if (attachView === "connectors" && !listed) {
515
+ attachBody.appendChild(el("div", "menu-hint", t("add.noConnectors")));
516
+ } else if (attachView === "plugins" && !listed) {
517
+ attachBody.appendChild(el("div", "menu-hint", t("add.noPlugins")));
518
+ } else if (!listed) {
519
+ attachBody.appendChild(el("div", "menu-empty", t("menu.none")));
520
+ }
521
+ if (attachView === "mention" && fileList.truncated) {
522
+ attachBody.appendChild(el("div", "menu-hint", t("add.more")));
523
+ }
524
+ }
525
+
526
+ function chooseAttachRow(row) {
527
+ if (row.type === "hint") return;
528
+ if (row.type === "up") return void showAttachView("root");
529
+ if (row.type === "into") return void showAttachView(row.kind);
530
+ if (row.type === "offer-ref") {
531
+ /**
532
+ * The same thing, named the same way, whichever menu it was picked from.
533
+ *
534
+ * A skill picked with @ lands in the sentence; picked from here it used to
535
+ * become a chip floating above the box instead — two representations of
536
+ * one idea, and the user has to learn which menu produces which. A
537
+ * connector keeps the chip: it is not something anybody writes into a
538
+ * sentence, it is a capability this message is sent with.
539
+ */
540
+ if (row.ref.kind === "skill" || row.ref.kind === "plugin") return void mentionAtCaret(row.ref.value);
541
+ return void toggleRef(row.ref);
542
+ }
543
+ if (row.type === "manage") {
544
+ closeAttachMenu();
545
+ return void openMarket(row.kind);
546
+ }
547
+ if (row.type === "pick") return void pickFromDisk(row.kind);
548
+ if (row.type === "market") {
549
+ closeAttachMenu();
550
+ return void openMarket(row.kind);
551
+ }
552
+ if (row.type === "upload") {
553
+ closeAttachMenu();
554
+ return void fileInput.click();
555
+ }
556
+ if (row.type === "command") return void insertCommand(row.command);
557
+ if (attachView === "mention" && row.ref) {
558
+ return void insertMention(row.ref.value);
559
+ }
560
+ toggleRef(row.ref);
561
+ }
562
+
563
+ /**
564
+ * Files, or a folder, from this machine — chosen in the platform's own dialog.
565
+ *
566
+ * The server opens it, because the server is the process that runs on the
567
+ * machine whose absolute paths the agent can act on; the browser's own input
568
+ * hands over bytes and withholds the path.
569
+ *
570
+ * What comes back is a reference where the agent can open the file itself, and
571
+ * a copy where it cannot. The dialog reaches the whole machine while the
572
+ * agent's readable roots are the project, so a screenshot chosen from the
573
+ * desktop would otherwise become a path every read of it is refused — an
574
+ * attachment that looks attached and is not. Which one happened is visible in
575
+ * the chip: a reference carries its folder, a copy carries its size.
576
+ */
577
+ async function pickFromDisk(kind) {
578
+ closeAttachMenu();
579
+ const picked = await api("/pick", kind === "folder" ? {} : { kind: "files" }, 5 * 60_000);
580
+ // A dismissed dialog is an answer, and the only one that says nothing.
581
+ if (picked.cancelled) return;
582
+ if (picked.unavailable || picked.error) return void addNotice(t("add.noDialog"), "err");
583
+
584
+ if (kind === "folder") {
585
+ // Relative when it is inside the project, which is shorter to read and is
586
+ // the form the agent's own tools take.
587
+ const path = picked.relative || picked.path;
588
+ if (!path) return;
589
+ // A folder has no bytes to carry in its place, so this is the one case
590
+ // left where all the page can do is say what will happen.
591
+ if (picked.unreachable) addNotice(t("add.outsideFolder", { name: path }), "err");
592
+ addRef("folder", path);
593
+ return;
594
+ }
595
+ // One dialog on macOS returns both kinds, so which it is comes from the
596
+ // disk rather than from the row that was clicked.
597
+ for (const entry of picked.entries || []) {
598
+ if (entry.staged) {
599
+ staged.push(await withPreview(entry.staged));
600
+ renderAttachments();
601
+ continue;
602
+ }
603
+ if (entry.unreachable) {
604
+ // Out of reach and too big to carry: both roads are closed, and the one
605
+ // thing worth doing is saying so before the turn is spent finding out.
606
+ addNotice(t(entry.dir ? "add.outsideFolder" : "add.outsideFile", { name: nameOf(entry.path) }), "err");
607
+ }
608
+ addRef(entry.dir ? "folder" : "file", entry.path);
609
+ }
610
+ }
611
+
612
+ /**
613
+ * A staged upload the browser never held the bytes of.
614
+ *
615
+ * Pulled down once and kept as an object URL rather than pointed at, because
616
+ * the staging map releases these the moment a prompt claims them — the bubble
617
+ * that draws the message is rendered just after that, and would be asking for
618
+ * something the server had already forgotten.
619
+ */
620
+ async function withPreview(file) {
621
+ if (file.kind !== "image") return file;
622
+ try {
623
+ const response = await fetch(q("/upload/preview?id=" + encodeURIComponent(file.id)));
624
+ if (!response.ok) return file;
625
+ return { ...file, preview: URL.createObjectURL(await response.blob()) };
626
+ } catch {
627
+ // A chip without a thumbnail is still a chip; nothing here is worth a
628
+ // notice the person cannot act on.
629
+ return file;
630
+ }
631
+ }
632
+
633
+ function showAttachView(view) {
634
+ attachView = view;
635
+ // No row is under the keyboard until a key moves there. Opening onto a
636
+ // highlighted first row, with the pointer highlighting a second, made the
637
+ // menu look like it had two selections and no rhythm.
638
+ attachCursor = -1;
639
+ paintAttachMenu();
640
+ attachMenu.focus();
641
+ }
642
+
643
+ // --- browsing connectors and plugins ---------------------------------------
644
+ /**
645
+ * What is on offer, in a dialog rather than in the menu.
646
+ *
647
+ * A connector is a name, a sentence about what it does, and a verb — that is a
648
+ * list with rows two lines tall, and the popup hanging off the + is 380px wide
649
+ * with room for one line. Squeezed in there the descriptions pushed the names
650
+ * out of their own rows. So the menu says which of the two lists you want, and
651
+ * the list gets a surface it fits on.
652
+ */
653
+
654
+ const marketDialog = document.getElementById("market");
655
+ const marketList = document.getElementById("mk-list");
656
+ let marketKind = "connectors";
657
+
658
+ const marketSearch = document.getElementById("mk-search");
659
+ marketSearch.addEventListener("input", () => paintMarket());
660
+
661
+ async function openMarket(kind) {
662
+ marketKind = kind;
663
+ // Cleared on open: a query left over from the last screen would hide most of
664
+ // this one and look like an empty catalogue.
665
+ marketSearch.value = "";
666
+ marketSearch.placeholder = t("menu.search");
667
+ const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors" };
668
+ const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote" };
669
+ document.getElementById("mk-title").textContent = t(titles[kind] || titles.connectors);
670
+ document.getElementById("mk-note").textContent = t(notes[kind] || notes.connectors);
671
+ marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
672
+ marketDialog.showModal();
673
+
674
+ const context = await getJson("/context");
675
+ if (!context.error) offer = context;
676
+ if (kind === "connectors") {
677
+ const body = await getJson("/registry");
678
+ market = body.error ? [] : body.connectors || [];
679
+ }
680
+ paintMarket();
681
+ }
682
+
683
+ function paintMarket() {
684
+ marketList.replaceChildren();
685
+ const query = (marketSearch.value || "").trim().toLowerCase();
686
+ const hit = (entry) =>
687
+ !query ||
688
+ (entry.name || "").toLowerCase().includes(query) ||
689
+ (entry.title || "").toLowerCase().includes(query) ||
690
+ (entry.description || "").toLowerCase().includes(query) ||
691
+ (entry.label || "").toLowerCase().includes(query);
692
+ const installed = (
693
+ marketKind === "plugins" ? offer.plugins || []
694
+ : marketKind === "skills" ? offer.skills || []
695
+ : offer.connectors || []
696
+ ).filter(hit);
697
+
698
+ if (installed.length) {
699
+ marketList.appendChild(el("div", "mk-group", t("market.installed")));
700
+ const grid = el("div", "mk-grid");
701
+ for (const entry of installed) grid.appendChild(installedRow(entry));
702
+ marketList.appendChild(grid);
703
+ }
704
+
705
+ // Only connectors have somewhere to come from yet. Saying so beats an
706
+ // "Available" heading over an empty space that reads as a failed load.
707
+ const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
708
+ if (available.length) {
709
+ marketList.appendChild(el("div", "mk-group", t("market.available")));
710
+ const grid = el("div", "mk-grid");
711
+ for (const entry of available) grid.appendChild(offerRow(entry));
712
+ marketList.appendChild(grid);
713
+ }
714
+ if (!installed.length && !available.length) {
715
+ marketList.appendChild(el("div", "mk-desc", t("market.empty")));
716
+ }
717
+ }
718
+
719
+ /**
720
+ * The mark for one connector: the vendor's icon, or a lettered tile.
721
+ *
722
+ * The image is swapped in only once it has decoded. A broken <img> shows the
723
+ * browser's own placeholder, which is uglier than the fallback and arrives at
724
+ * a different moment in every browser.
725
+ */
726
+ function connectorMark(id, label) {
727
+ const letter = (label || id || "?").trim().charAt(0).toUpperCase();
728
+ // A stable hue per connector, so the tile is the same colour every time
729
+ // rather than a different one each render.
730
+ let hash = 0;
731
+ for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
732
+ const mono = el("span", "mk-mono", letter);
733
+ mono.style.background = "hsl(" + hash + " 42% 42%)";
734
+
735
+ const img = new Image();
736
+ img.className = "mk-logo";
737
+ img.alt = "";
738
+ img.addEventListener("load", () => {
739
+ if (isDarkFlatGlyph(img)) img.classList.add("invert-dark");
740
+ mono.replaceWith(img);
741
+ });
742
+ img.src = q("/connector-icon?id=" + encodeURIComponent(id));
743
+ return mono;
744
+ }
745
+
746
+ /**
747
+ * What kind of mark an image is.
748
+ *
749
+ * Measured rather than guessed, because there is no way to tell from a URL or
750
+ * a content type which logos are the black-on-transparent kind. The image is
751
+ * already decoded and comes from this origin, so a 32px sample of it is a few
752
+ * hundred microseconds and taints nothing.
753
+ *
754
+ * Two answers come out of the one sample. Filled is whether the mark brought
755
+ * its own background — a tile with a glyph punched out of it, which owns every
756
+ * pixel of its box. Invert is whether it is a dark shape on nothing, which in
757
+ * the dark theme is a black shape on a black row.
758
+ */
759
+ function markProfile(img) {
760
+ const unknown = { filled: false, invert: false };
761
+ try {
762
+ const size = 32;
763
+ const canvas = document.createElement("canvas");
764
+ canvas.width = canvas.height = size;
765
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
766
+ ctx.drawImage(img, 0, 0, size, size);
767
+ const pixels = ctx.getImageData(0, 0, size, size).data;
768
+
769
+ let opaque = 0;
770
+ let dark = 0;
771
+ let coloured = 0;
772
+ for (let i = 0; i < pixels.length; i += 4) {
773
+ if (pixels[i + 3] < 40) continue;
774
+ opaque++;
775
+ const hi = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
776
+ const lo = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
777
+ if (hi - lo > 40) coloured++;
778
+ if (hi < 110) dark++;
779
+ }
780
+ if (!opaque) return unknown;
781
+
782
+ // A mark that fills its whole box brought its own background, and whatever
783
+ // it drew on that background is its business.
784
+ const filled = opaque / (size * size) > 0.75;
785
+ if (filled) return { filled: true, invert: false };
786
+ return { filled: false, invert: dark / opaque > 0.7 && coloured / opaque < 0.15 };
787
+ } catch {
788
+ // A canvas that will not read is not worth a broken icon.
789
+ return unknown;
790
+ }
791
+ }
792
+
793
+ /** The half of the profile the connector list asks for. */
794
+ function isDarkFlatGlyph(img) {
795
+ return markProfile(img).invert;
796
+ }
797
+
798
+ /** One thing this harness already has: attachable, and removable if we added it. */
799
+ function installedRow(entry) {
800
+ const row = el("div", "mk-item");
801
+ const isPlugin = marketKind === "plugins";
802
+ const isSkill = marketKind === "skills";
803
+ row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
804
+
805
+ const naming = el("div", "mk-naming");
806
+ // A plugin may carry a display title over its id; the id is still what a
807
+ // reference resolves by, so it is the title that is painted and nothing else.
808
+ const heading = el("div", "mk-name", entry.title || entry.name);
809
+ /**
810
+ * Where it came from, when that is not "this machine".
811
+ *
812
+ * A list of eighty skills is mostly things somebody installed, and the four
813
+ * that ship here look exactly like them — which matters the moment one
814
+ * misbehaves and the question is whose it is. Runtime plugins get the same
815
+ * treatment for the same reason: memory and media are ours, and anything
816
+ * beside them was configured.
817
+ */
818
+ // A runtime plugin says so in its source; a skill carries its own flag. Reading
819
+ // only one of them is how the two that ship here — memory and the media
820
+ // plugin — ended up as the only rows in the panel with no provenance at all.
821
+ const tag =
822
+ entry.builtin || entry.source === "builtin" ? "built-in"
823
+ : entry.source === "runtime" ? "runtime"
824
+ : "";
825
+ if (tag) heading.appendChild(el("span", "mk-tag", tag));
826
+ naming.appendChild(heading);
827
+ // A skill has nothing to connect to and nothing to be offline from, so the
828
+ // slot that carries a connector's state carries what the skill is for —
829
+ // which is also the sentence the model decides on.
830
+ const detail = isSkill
831
+ ? entry.description || entry.source
832
+ : isPlugin
833
+ ? entry.description || entry.source
834
+ : entry.connected
835
+ ? t("add.tools", { n: entry.tools })
836
+ : entry.needsAuth
837
+ ? t("add.needsAuth")
838
+ : entry.error || t("add.offline");
839
+ if (detail) naming.appendChild(el("div", "mk-desc", detail));
840
+ row.appendChild(naming);
841
+
842
+ const acts = el("div", "mk-acts");
843
+ // Not where the plugin came from — the note above the list says that once,
844
+ // and repeating it on every row distinguishes nothing. What is worth saying
845
+ // per row is the part that would otherwise surprise someone: this plugin
846
+ // declares servers, and they are not running.
847
+ const idle = isPlugin && entry.source === "claude" ? (entry.servers || []).length : 0;
848
+ // Two keys rather than a plural engine: this is the only place in the page
849
+ // where the count is usually one, and "1 servers" is worth the extra string.
850
+ if (idle) {
851
+ const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
852
+ acts.appendChild(el("span", "mk-state", said));
853
+ }
854
+ const ref = { kind: isPlugin ? "plugin" : isSkill ? "skill" : "connector", value: entry.name, label: entry.name };
855
+ const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
856
+ attach.type = "button";
857
+ attach.title = t("market.attach");
858
+ attach.addEventListener("click", () => {
859
+ toggleRef(ref);
860
+ attach.textContent = referenced(ref) ? "✓" : "+";
861
+ });
862
+ acts.appendChild(attach);
863
+
864
+ // A connector that asked to be signed in to gets the one button that moves
865
+ // it forward, in front of the one that removes it.
866
+ if (!isPlugin && !isSkill && entry.needsAuth) {
867
+ const go = el(
868
+ "button",
869
+ "mk-btn go",
870
+ t(entry.name === authorizing ? "market.authorizing" : "market.authorize"),
871
+ );
872
+ go.type = "button";
873
+ go.disabled = Boolean(authorizing);
874
+ go.addEventListener("click", () => void authorizeConnector(entry.name));
875
+ acts.appendChild(go);
876
+ }
877
+
878
+ // Removal is a connector's alone: a plugin is a directory on disk and a
879
+ // skill is a file in one, and neither is undone from a composer menu.
880
+ if (!isPlugin && !isSkill) {
881
+ const drop = el("button", "mk-icon-btn drop", "×");
882
+ drop.type = "button";
883
+ drop.title = t("add.remove");
884
+ drop.addEventListener("click", () => void installConnector(entry.name, true));
885
+ acts.appendChild(drop);
886
+ }
887
+ row.appendChild(acts);
888
+ return row;
889
+ }
890
+
891
+ /** One thing on offer that is not here yet. */
892
+ function offerRow(entry) {
893
+ const row = el("div", "mk-item");
894
+ row.appendChild(connectorMark(entry.id, entry.label));
895
+
896
+ const naming = el("div", "mk-naming");
897
+ naming.appendChild(el("div", "mk-name", entry.label));
898
+ // What it needs takes the line, because it is the thing standing between
899
+ // this row and a working connector.
900
+ const missing = entry.missingEnv || [];
901
+ const desc = missing.length ? t("market.needsEnv", { names: missing.join(", ") }) : entry.description;
902
+ if (desc) naming.appendChild(el("div", missing.length ? "mk-desc warn" : "mk-desc", desc));
903
+ row.appendChild(naming);
904
+
905
+ const acts = el("div", "mk-acts");
906
+ const add = el("button", "mk-btn go", t(entry.id === adding ? "market.adding" : "market.add"));
907
+ add.type = "button";
908
+ add.disabled = Boolean(adding) || missing.length > 0;
909
+ add.addEventListener("click", () => void installConnector(entry.id, false));
910
+ acts.appendChild(add);
911
+ row.appendChild(acts);
912
+ return row;
913
+ }
914
+
915
+ /**
916
+ * Add a connector, or take one away.
917
+ *
918
+ * Both halves happen on the server — connected now, written to the config for
919
+ * next time — so the only thing left here is to say which row is waiting and
920
+ * to repaint from what came back rather than from what was hoped for.
921
+ */
922
+ async function installConnector(id, remove) {
923
+ if (adding) return;
924
+ adding = remove ? null : id;
925
+ paintMarket();
926
+ const body = await api("/connector", { id: id, remove: remove === true }, 3 * 60_000);
927
+ adding = null;
928
+ if (body.error) {
929
+ addNotice(body.error, "err");
930
+ return void paintMarket();
931
+ }
932
+ if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
933
+ for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
934
+ paintMarket();
935
+ }
936
+
937
+ /**
938
+ * Sign in to a connector.
939
+ *
940
+ * The redirect does not come back here — it lands on a loopback listener the
941
+ * harness opened for the length of the flow — so there is nothing to poll and
942
+ * nothing to post back. The row goes quiet, the tab does the work, and the
943
+ * the connectors_changed broadcast repaints when it lands.
944
+ */
945
+ async function authorizeConnector(id) {
946
+ if (authorizing) return;
947
+ authorizing = id;
948
+ paintMarket();
949
+ const body = await api("/connector/authorize", { id: id }, 60_000);
950
+ if (!body || body.error) {
951
+ authorizing = null;
952
+ paintMarket();
953
+ return void addNotice((body && body.error) || "could not start the sign-in", "err");
954
+ }
955
+ // Opened rather than linked, for the same reason the website sign-in is:
956
+ // in the desktop build a link would load the page inside the app.
957
+ window.open(body.url, "_blank", "noopener");
958
+ addNotice(t("market.authOpened", { name: id }));
959
+ }
960
+
961
+ document.getElementById("mk-close").appendChild(svgIcon("close"));
962
+ document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
963
+ document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
964
+
965
+ async function loadMentionFiles() {
966
+ const seq = ++attachSeq;
967
+ const base = "/files?session=" + encodeURIComponent(activeId);
968
+ // A bare @ lists the project root, so the menu is useful before the first
969
+ // letter is typed.
970
+ const path = attachQuery ? base + "&q=" + encodeURIComponent(attachQuery) : base + "&dir=";
971
+ const body = await getJson(path);
972
+ if (seq !== attachSeq) return;
973
+ if (body.error) {
974
+ fileList = { entries: [] };
975
+ addNotice(body.error, "err");
976
+ } else {
977
+ fileList = body;
978
+ }
979
+ attachCursor = 0;
980
+ paintAttachMenu();
981
+ }
982
+
983
+ /** Debounced, because typing is faster than a walk of the tree. */
984
+ function searchMentionFiles() {
985
+ clearTimeout(attachTimer);
986
+ attachTimer = setTimeout(() => void loadMentionFiles(), 120);
987
+ }
988
+
989
+ function closeAttachMenu() {
990
+ attachMenu.hidden = true;
991
+ attachPicker.classList.remove("open");
992
+ attachMenu.style.bottom = "";
993
+ mentionAt = -1;
994
+ // Back to the offers: the next + should not open onto whichever screen this
995
+ // one happened to close on.
996
+ attachView = "root";
997
+ }
998
+
999
+ function openAttachMenu() {
1000
+ for (const other of document.querySelectorAll(".picker.open")) {
1001
+ if (other !== attachPicker) {
1002
+ other.classList.remove("open");
1003
+ other.querySelector(".menu").hidden = true;
1004
+ }
1005
+ }
1006
+ attachMenu.style.bottom = "";
1007
+ mentionAt = -1;
1008
+ attachMenu.hidden = false;
1009
+ attachPicker.classList.add("open");
1010
+ showAttachView("root");
1011
+
1012
+ // Refreshed on every open rather than cached for the page: switching
1013
+ // projects changes what is connected, and a connector can drop between two
1014
+ // messages.
1015
+ void getJson("/context").then((body) => {
1016
+ if (body.error) return;
1017
+ offerLoaded = true;
1018
+ if (attachMenu.hidden) return;
1019
+ offer = body;
1020
+ paintAttachMenu();
1021
+ });
1022
+ }
1023
+
1024
+ /**
1025
+ * The same list, for a mention that was never opened from the + button.
1026
+ *
1027
+ * The + menu refreshes on every open because a connector can drop between two
1028
+ * messages. A mention cannot do that: it reopens on every keystroke, and a
1029
+ * fetch per character is a fetch per character. So it asks once, and a skill
1030
+ * list that is a few minutes stale is the right trade against a skill list
1031
+ * that is empty because nobody happened to click the +.
1032
+ */
1033
+ function ensureOffer() {
1034
+ if (offerLoaded) return;
1035
+ offerLoaded = true;
1036
+ void getJson("/context").then((body) => {
1037
+ if (body.error) return;
1038
+ offer = body;
1039
+ if (attachView === "mention") paintAttachMenu();
1040
+ });
1041
+ }
1042
+
1043
+ /**
1044
+ * Arrow keys and Enter over the rows, shared by the menu and the mention list.
1045
+ *
1046
+ * Returns whether the key was spent here, so the caller knows to leave the
1047
+ * textarea's own handling alone.
1048
+ */
1049
+ function moveAttachCursor(e) {
1050
+ const rows = attachRows();
1051
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1052
+ e.preventDefault();
1053
+ if (!rows.length) return true;
1054
+ const step = e.key === "ArrowDown" ? 1 : -1;
1055
+ // Past the headings rather than onto them: a heading is not a choice, and
1056
+ // a cursor resting on one is a keypress that appears to do nothing.
1057
+ let next = attachCursor;
1058
+ for (let i = 0; i < rows.length; i++) {
1059
+ next = (next + step + rows.length) % rows.length;
1060
+ if (rows[next].type !== "group") break;
1061
+ }
1062
+ attachCursor = next;
1063
+ paintAttachMenu();
1064
+ const active = attachBody.querySelector(".cursor");
1065
+ if (active) active.scrollIntoView({ block: "nearest" });
1066
+ return true;
1067
+ }
1068
+ if (e.key === "Enter" || (e.key === "Tab" && (attachView === "mention" || attachView === "command"))) {
1069
+ e.preventDefault();
1070
+ const row = rows[attachCursor];
1071
+ if (row) chooseAttachRow(row);
1072
+ return true;
1073
+ }
1074
+ return false;
1075
+ }
1076
+
1077
+ // --- the chip behind a mention ---------------------------------------------
1078
+
1079
+ const inputMirror = document.getElementById("input-mirror");
1080
+
1081
+ /** Every name an @ can stand for. */
1082
+ function mentionNames() {
1083
+ return new Map([
1084
+ ...(offer.skills || []).map((skill) => [skill.name, "skill"]),
1085
+ ...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
1086
+ ]);
1087
+ }
1088
+
1089
+ /**
1090
+ * Backspace takes the whole mention, not a letter of it.
1091
+ *
1092
+ * The chip is drawn as one object and behaves as one everywhere else — it is
1093
+ * inserted whole and sent whole — so deleting it a character at a time leaves
1094
+ * a trail of half-names that are no longer anything, each of them still
1095
+ * looking like a word the harness understood until the wash disappears. Only
1096
+ * when the caret is against it and nothing is selected: a selection is the
1097
+ * user being specific, and editing inside a name is allowed, it just is not
1098
+ * what a keystroke at the edge means.
1099
+ */
1100
+ input.addEventListener("keydown", (e) => {
1101
+ if (e.key !== "Backspace" && e.key !== "Delete") return;
1102
+ if (input.selectionStart !== input.selectionEnd) return;
1103
+ const caret = input.selectionStart;
1104
+ const names = mentionNames();
1105
+ if (!names.size) return;
1106
+
1107
+ let from = caret;
1108
+ let to = caret;
1109
+ if (e.key === "Backspace") {
1110
+ const hit = /(^|\s)@([^\s@]+)$/.exec(input.value.slice(0, caret));
1111
+ if (!hit || !names.has(hit[2])) return;
1112
+ from = caret - hit[2].length - 1;
1113
+ } else {
1114
+ const after = input.value.slice(caret);
1115
+ const hit = /^@([^\s@]+)/.exec(after);
1116
+ // Forward-delete only from the start of one: mid-name it is an ordinary
1117
+ // character, and the caret is visibly inside a word.
1118
+ const start = caret === 0 || /\s/.test(input.value[caret - 1] ?? "");
1119
+ if (!hit || !start || !names.has(hit[1])) return;
1120
+ to = caret + hit[1].length + 1;
1121
+ }
1122
+
1123
+ e.preventDefault();
1124
+ input.value = input.value.slice(0, from) + input.value.slice(to);
1125
+ input.setSelectionRange(from, from);
1126
+ paintMirror();
1127
+ // The box may now fit in fewer lines, and the grower only runs on input.
1128
+ autosize();
1129
+ });
1130
+
1131
+ /**
1132
+ * Paint the backdrop: the same text, with the mentions in it marked.
1133
+ *
1134
+ * Only names that are actually a skill or a plugin are marked. Highlighting
1135
+ * any @word would promise something about an email address and a typo, and
1136
+ * the promise this makes — that the harness knows what was named — is exactly
1137
+ * what a reader is checking when they glance at it.
1138
+ */
1139
+ function paintMirror() {
1140
+ if (!inputMirror) return;
1141
+ // Typing an @ asks for the list, but text can also arrive whole — pasted, or
1142
+ // restored as a draft — and that text has mentions in it too. Guarded, so
1143
+ // this is one request in the life of the page rather than one per keystroke.
1144
+ ensureOffer();
1145
+ const names = mentionNames();
1146
+ inputMirror.replaceChildren();
1147
+ if (!names.size) return void (inputMirror.textContent = input.value);
1148
+
1149
+ const text = input.value;
1150
+ let at = 0;
1151
+ // Word-initial, matching what the mention parser accepts, so what lights up
1152
+ // is what the menu would have offered.
1153
+ const pattern = /(^|\s)@([^\s@]+)/g;
1154
+ for (let hit = pattern.exec(text); hit; hit = pattern.exec(text)) {
1155
+ const start = hit.index + hit[1].length;
1156
+ if (!names.has(hit[2])) continue;
1157
+ inputMirror.appendChild(document.createTextNode(text.slice(at, start)));
1158
+ const mark = el("mark", "");
1159
+ const slot = el("span", "at");
1160
+ slot.appendChild(svgIcon(names.get(hit[2])));
1161
+ mark.appendChild(slot);
1162
+ mark.appendChild(document.createTextNode(hit[2]));
1163
+ inputMirror.appendChild(mark);
1164
+ at = start + hit[2].length + 1;
1165
+ }
1166
+ inputMirror.appendChild(document.createTextNode(text.slice(at)));
1167
+ if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
1168
+ }
1169
+
1170
+ // --- the caret ------------------------------------------------------------
1171
+
1172
+ /**
1173
+ * Draw the caret, because the field cannot draw one thick enough.
1174
+ *
1175
+ * A textarea's caret is one device pixel and the only thing CSS offers is its
1176
+ * colour. On a pale field that bar is genuinely hard to find, so the native
1177
+ * one is hidden and this one is put where it would have been.
1178
+ *
1179
+ * Where that is comes from a probe: the same box as the mirror, with the same
1180
+ * font, width, padding and wrapping, holding the text up to the caret and
1181
+ * then a zero-width marker. Whatever rectangle the marker lands in is where
1182
+ * the field would have drawn the bar — including after a wrap, which is the
1183
+ * case arithmetic on character widths always gets wrong.
1184
+ */
1185
+ const caretEl = document.getElementById("caret");
1186
+ const caretProbe = document.getElementById("caret-probe");
1187
+ let caretRest;
1188
+
1189
+ function placeCaret() {
1190
+ // Only when the field has the caret and nothing is selected. A selection
1191
+ // draws its own edges, and a bar inside one reads as a second insertion
1192
+ // point.
1193
+ const showing =
1194
+ document.activeElement === input && input.selectionStart === input.selectionEnd;
1195
+ caretEl.classList.toggle("on", showing);
1196
+ if (!showing) return;
1197
+
1198
+ const head = input.value.slice(0, input.selectionStart);
1199
+ caretProbe.textContent = head;
1200
+ // A trailing newline collapses unless something follows it, which would put
1201
+ // the caret at the end of the line above rather than on the empty one.
1202
+ const marker = document.createElement("span");
1203
+ marker.textContent = String.fromCharCode(0x200b);
1204
+ caretProbe.appendChild(marker);
1205
+
1206
+ // The marker's offsets are inside the probe; the caret is placed against
1207
+ // the wrapper they share. The probe is inset and padded so the mention chip
1208
+ // has somewhere to bleed — read those numbers off it rather than repeating
1209
+ // them here, or the caret drifts the day the padding changes. Scrolling is
1210
+ // the field's, not the probe's.
1211
+ // The line box, which is what the field's own caret fills. Nothing here is
1212
+ // trying to improve on the native geometry — the only reason this caret
1213
+ // exists is that CSS offers no way to make that one wider than a pixel.
1214
+ caretEl.style.left = caretProbe.offsetLeft + marker.offsetLeft + "px";
1215
+ caretEl.style.top = caretProbe.offsetTop + marker.offsetTop - input.scrollTop + "px";
1216
+ caretEl.style.height = marker.offsetHeight + "px";
1217
+
1218
+ // Solid while it is moving, blinking once it settles.
1219
+ caretEl.classList.add("moving");
1220
+ clearTimeout(caretRest);
1221
+ caretRest = setTimeout(() => caretEl.classList.remove("moving"), 420);
1222
+ }
1223
+
1224
+ // Every way the caret can move. The selectionchange event covers the keyboard
1225
+ // and the mouse both, and fires for a textarea in every browser this ships to.
1226
+ for (const event of ["input", "focus", "blur", "scroll"]) {
1227
+ input.addEventListener(event, placeCaret);
1228
+ }
1229
+ document.addEventListener("selectionchange", () => {
1230
+ if (document.activeElement === input) placeCaret();
1231
+ });
1232
+ window.addEventListener("resize", placeCaret);
1233
+
1234
+ /** How wide the field draws an @ sign: the slot the icon has to fit in. */
1235
+ function atWidth() {
1236
+ const probe = document.createElement("canvas").getContext("2d");
1237
+ probe.font = getComputedStyle(input).font;
1238
+ return probe.measureText("@").width;
1239
+ }
1240
+
1241
+ // The mirror has no scrollbar of its own; the text it holds is the text the
1242
+ // textarea is scrolling.
1243
+ input.addEventListener("scroll", () => {
1244
+ if (inputMirror) inputMirror.scrollTop = input.scrollTop;
1245
+ });
1246
+
1247
+ // --- / commands ------------------------------------------------------------
1248
+
1249
+ /**
1250
+ * What a slash does that the sidebar does not.
1251
+ *
1252
+ * The palette on Cmd-K is navigation: go somewhere, open something. These are
1253
+ * the other half — act on the session in front of you — and they are here
1254
+ * because that half had no surface at all. A command that only opens a panel
1255
+ * belongs in the palette, not in this list; two ways to reach the same screen
1256
+ * is how a user ends up unsure which one has the thing they want.
1257
+ *
1258
+ * Each takes the rest of the line as its argument, which is why the menu
1259
+ * inserts a trailing space: the next thing typed is the argument, not a
1260
+ * search for another command.
1261
+ */
1262
+ const COMMANDS = [
1263
+ {
1264
+ name: "model",
1265
+ arg: "id",
1266
+ hint: () => t("cmd.model"),
1267
+ run: (arg) => {
1268
+ if (!arg) return void document.querySelector("#model-picker .picker-btn")?.click();
1269
+ return void api("/model", { model: arg }).then(pushNotice);
1270
+ },
1271
+ },
1272
+ {
1273
+ name: "login",
1274
+ arg: "",
1275
+ hint: () => t("cmd.login"),
1276
+ // The same dialog the sidebar's sign-in row opens, so every way in — the
1277
+ // website and the emailed code — is on screen.
1278
+ run: async () => {
1279
+ const account = await getJson("/account");
1280
+ openConnect((account && account.provider) || "official");
1281
+ },
1282
+ },
1283
+ {
1284
+ name: "mode",
1285
+ arg: "mode",
1286
+ hint: () => t("cmd.mode"),
1287
+ run: (arg) => {
1288
+ if (!arg) return void document.querySelector("#mode-picker .picker-btn")?.click();
1289
+ return void api("/mode", { mode: arg }).then(pushNotice);
1290
+ },
1291
+ },
1292
+ {
1293
+ name: "rename",
1294
+ arg: "title",
1295
+ hint: () => t("cmd.rename"),
1296
+ run: (arg) => {
1297
+ if (!arg) return void addNotice(t("cmd.needsTitle"), "notice err");
1298
+ return void api("/rename", { id: activeId, title: arg }).then(pushNotice);
1299
+ },
1300
+ },
1301
+ {
1302
+ name: "schedule",
1303
+ arg: "name",
1304
+ hint: () => t("cmd.schedule"),
1305
+ run: (arg) => {
1306
+ // The panel manages routines; this runs one without leaving the
1307
+ // composer. Creating one from here would need somewhere to keep it,
1308
+ // which is the panel's job and not a line of text's.
1309
+ if (!arg) return void document.getElementById("nav-routines")?.click();
1310
+ return void api("/routine", { name: arg }).then(pushNotice);
1311
+ },
1312
+ },
1313
+ {
1314
+ name: "new",
1315
+ arg: "",
1316
+ hint: () => t("cmd.new"),
1317
+ run: () => void switchTo({ action: "new" }),
1318
+ },
1319
+ ];
1320
+
1321
+ function pushNotice(body) {
1322
+ if (body && body.error) addNotice(body.error, "notice err");
1323
+ }
1324
+
1325
+ /** The /word being typed, or null. Only ever the first word of the box. */
1326
+ function commandUnderCaret() {
1327
+ // Anchored at the start, and only while the first word is still being
1328
+ // typed: a path is full of slashes and "and/or" is a word, and neither is
1329
+ // an attempt to run something.
1330
+ const before = input.value.slice(0, input.selectionStart);
1331
+ const match = /^\/([a-z-]*)$/i.exec(before);
1332
+ return match ? { query: match[1] } : null;
1333
+ }
1334
+
1335
+ function openCommands(command) {
1336
+ ensureOffer();
1337
+ attachView = "command";
1338
+ attachQuery = command.query.trim().toLowerCase();
1339
+ attachCursor = 0;
1340
+ attachMenu.hidden = false;
1341
+ attachPicker.classList.add("open");
1342
+ const bar = attachPicker.getBoundingClientRect().top;
1343
+ const box = composerBox.getBoundingClientRect().top;
1344
+ // On a phone the menu is positioned from the box itself by the stylesheet.
1345
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
1346
+ paintAttachMenu();
1347
+ }
1348
+
1349
+ /** Insert the command, with the space its argument starts after. */
1350
+ function insertCommand(name) {
1351
+ const rest = input.value.slice(input.selectionStart);
1352
+ input.value = "/" + name + " " + rest.replace(/^\s+/, "");
1353
+ paintMirror();
1354
+ closeAttachMenu();
1355
+ input.focus();
1356
+ const at = name.length + 2;
1357
+ input.setSelectionRange(at, at);
1358
+ }
1359
+
1360
+ /**
1361
+ * Run what was typed, or say it is not a command.
1362
+ *
1363
+ * Returns whether the line was handled, so the submit path can tell a command
1364
+ * from a prompt. An unknown slash word is a prompt: people write /etc and
1365
+ * and/or, and refusing to send those would be the feature getting in the way
1366
+ * of the thing it was added to.
1367
+ */
1368
+ function runCommand(text) {
1369
+ const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/i.exec(text);
1370
+ if (!match) return false;
1371
+ const command = COMMANDS.find((one) => one.name === match[1].toLowerCase());
1372
+ if (!command) return false;
1373
+ command.run((match[2] || "").trim());
1374
+ return true;
1375
+ }
1376
+
1377
+ // --- @ mentions ------------------------------------------------------------
1378
+
1379
+ /** The @word being typed at the caret, or null. */
1380
+ function mentionUnderCaret() {
1381
+ const caret = input.selectionStart;
1382
+ const before = input.value.slice(0, caret);
1383
+ // Only at the start of a word: an email address and a decorator are not
1384
+ // attempts to name a file.
1385
+ const match = /(^|\s)@([^\s@]*)$/.exec(before);
1386
+ return match ? { at: caret - match[2].length - 1, query: match[2] } : null;
1387
+ }
1388
+
1389
+ function openMention(mention) {
1390
+ ensureOffer();
1391
+ mentionAt = mention.at;
1392
+ attachView = "mention";
1393
+ attachQuery = mention.query.trim();
1394
+ attachCursor = 0;
1395
+ attachMenu.hidden = false;
1396
+ attachPicker.classList.add("open");
1397
+ // Clear of the whole composer, not just of the + it hangs from: the menu
1398
+ // otherwise covers the line being typed, and a mention is chosen by reading
1399
+ // the sentence it is going into.
1400
+ const bar = attachPicker.getBoundingClientRect().top;
1401
+ const box = composerBox.getBoundingClientRect().top;
1402
+ // On a phone the menu is positioned from the box itself by the stylesheet.
1403
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
1404
+ paintAttachMenu();
1405
+ searchMentionFiles();
1406
+ }
1407
+
1408
+ /** Write an @name into the box at the caret, from a menu that is not the @ one. */
1409
+ function mentionAtCaret(name) {
1410
+ const caret = input.selectionStart ?? input.value.length;
1411
+ const before = input.value.slice(0, caret);
1412
+ const head = roomBefore(before) + "@" + name + " ";
1413
+ input.value = head + input.value.slice(caret);
1414
+ closeAttachMenu();
1415
+ input.focus();
1416
+ input.setSelectionRange(head.length, head.length);
1417
+ paintMirror();
1418
+ }
1419
+
1420
+ /**
1421
+ * Two spaces on either side of a mention, not one.
1422
+ *
1423
+ * The chip's horizontal padding is taken out of the whitespace next to it,
1424
+ * because the field decides where every glyph lands and the mirror cannot add
1425
+ * width of its own. One space is four pixels: enough for the wash to clear
1426
+ * the letters, not enough for the chip to read as an object with room around
1427
+ * it. A second space gives four pixels of padding and four more between the
1428
+ * chip and the next word; three between two chips, which each take four of
1429
+ * them. The model sees the extra spaces, which it reads the same as one.
1430
+ */
1431
+ function roomBefore(before) {
1432
+ if (!before || /\n$/.test(before)) return before;
1433
+ const bare = before.replace(/ +$/, "");
1434
+ // Two chips in a row each take four pixels of the gap between them, so
1435
+ // two spaces would leave them touching. A third keeps the same four pixels
1436
+ // of daylight a chip has from a word.
1437
+ const last = /@([^\s@]+)$/.exec(bare);
1438
+ const afterChip = last && mentionNames().has(last[1]);
1439
+ return bare + (afterChip ? " " : " ");
1440
+ }
1441
+
1442
+ function insertMention(path) {
1443
+ const caret = input.selectionStart;
1444
+ const head = roomBefore(input.value.slice(0, mentionAt)) + "@" + path + " ";
1445
+ input.value = head + input.value.slice(caret);
1446
+ paintMirror();
1447
+ closeAttachMenu();
1448
+ input.focus();
1449
+ input.setSelectionRange(head.length, head.length);
1450
+ // The trailing space closes the mention the next check would otherwise
1451
+ // reopen, and the autosize handler needs the event either way.
1452
+ input.dispatchEvent(new Event("input"));
1453
+ }
1454
+
1455
+ input.addEventListener("input", () => {
1456
+ const command = commandUnderCaret();
1457
+ if (command) return void openCommands(command);
1458
+ const mention = mentionUnderCaret();
1459
+ if (mention) return void openMention(mention);
1460
+ if (attachView === "mention" || attachView === "command") closeAttachMenu();
1461
+ });
1462
+
1463
+ // Before the composer's own Enter handling, which would otherwise send the
1464
+ // message out from under a menu the user was choosing from.
1465
+ input.addEventListener("keydown", (e) => {
1466
+ if (attachView !== "mention" || attachMenu.hidden) return;
1467
+ if (e.key === "Escape") {
1468
+ e.stopImmediatePropagation();
1469
+ return void closeAttachMenu();
1470
+ }
1471
+ if (moveAttachCursor(e)) e.stopImmediatePropagation();
1472
+ });
1473
+
1474
+ attachMenu.addEventListener("keydown", (e) => {
1475
+ if (attachView === "mention") return;
1476
+ if (e.key === "Escape") return void closeAttachMenu();
1477
+ moveAttachCursor(e);
1478
+ });
1479
+
1480
+ document.getElementById("attach").addEventListener("click", (e) => {
1481
+ e.stopPropagation();
1482
+ attachMenu.hidden ? openAttachMenu() : closeAttachMenu();
1483
+ });
1484
+ attachMenu.addEventListener("click", (e) => e.stopPropagation());
1485
+ document.addEventListener("click", closeAttachMenu);
1486
+ document.addEventListener("keydown", (e) => {
1487
+ if (e.key === "Escape" && !attachMenu.hidden) closeAttachMenu();
1488
+ });
1489
+
1490
+
1491
+ // --- composer -------------------------------------------------------------
1492
+
1493
+ document.getElementById("composer").addEventListener("submit", (e) => {
1494
+ e.preventDefault();
1495
+ const text = input.value.trim();
1496
+ // A command runs instead of being sent. Unknown slash words fall through and
1497
+ // are sent as prose, because /etc and and/or are things people write.
1498
+ if (runCommand(text)) {
1499
+ input.value = "";
1500
+ autosize();
1501
+ paintMirror();
1502
+ closeAttachMenu();
1503
+ return;
1504
+ }
1505
+ // A question waiting in this tab takes what is typed as its answer. People
1506
+ // answer "none of these — I want..." in the composer, and a second turn could
1507
+ // not start while this one waits anyway.
1508
+ const waiting = openQuestion(activeId);
1509
+ if (waiting && text && !staged.length) {
1510
+ input.value = "";
1511
+ autosize();
1512
+ paintMirror();
1513
+ void sendQuestion(waiting, { outcome: "dismissed", note: text });
1514
+ return;
1515
+ }
1516
+ if (!text && !staged.length && !refs.length) {
1517
+ // Send on an empty composer normally does nothing. With messages left
1518
+ // waiting — the turn under them was stopped, or it failed — it is the
1519
+ // release: the server does not restart a queue after either by itself.
1520
+ const here = panes.get(activeId);
1521
+ if (here && !here.busy && (here.queued || []).length) void api("/flush", { session: activeId });
1522
+ return;
1523
+ }
1524
+ const ids = staged.map((file) => file.id);
1525
+ const shots = staged
1526
+ .filter((file) => file.preview)
1527
+ .map((file) => ({ src: file.preview, name: file.name }));
1528
+ if (shots.length) sentPreviews.set(activeId, shots);
1529
+ const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
1530
+ input.value = "";
1531
+ autosize();
1532
+ paintMirror();
1533
+ staged = [];
1534
+ refs = [];
1535
+ renderAttachments();
1536
+ // The counts, never the text, the file names or the paths.
1537
+ track("prompt_sent", { attachments: ids.length, references: referenced.length });
1538
+ void api("/prompt", {
1539
+ session: activeId,
1540
+ prompt: text,
1541
+ attachments: ids,
1542
+ references: referenced,
1543
+ });
1544
+ });
1545
+
1546
+ input.addEventListener("keydown", (e) => {
1547
+ if (e.key === "Enter" && !e.shiftKey) {
1548
+ e.preventDefault();
1549
+ document.getElementById("composer").requestSubmit();
1550
+ }
1551
+ });
1552
+ input.addEventListener("input", () => {
1553
+ autosize();
1554
+ paintMirror();
1555
+ placeCaret();
1556
+ });
1557
+
1558
+ // --- the field's height ---------------------------------------------------
1559
+
1560
+ /**
1561
+ * How tall the field is: grown to fit what is typed, or what it was dragged to.
1562
+ *
1563
+ * FIELD_MIN matches the CSS floor, so deleting a long paste cannot leave the
1564
+ * box taller than an empty one. The ceilings are a share of the window rather
1565
+ * than fixed numbers: the field must not swallow the conversation on a short
1566
+ * screen. Growing on its own stops at a third of the window; a person dragging
1567
+ * is allowed most of it, because they can see what they are giving up.
1568
+ */
1569
+ const FIELD_MIN = 56;
1570
+ const fieldGrowMax = () => window.innerHeight * 0.32;
1571
+ const fieldDragMax = () => Math.max(FIELD_MIN, window.innerHeight * 0.7);
1572
+ const FIELD_KEY = "oharness.composerHeight";
1573
+
1574
+ /** Set by dragging the grip; null means the field sizes itself. */
1575
+ let fieldHeight = null;
1576
+ try {
1577
+ const saved = Number(localStorage.getItem(FIELD_KEY));
1578
+ if (Number.isFinite(saved) && saved >= FIELD_MIN) fieldHeight = saved;
1579
+ } catch { /* private window, or storage turned off */ }
1580
+
1581
+ function autosize() {
1582
+ if (fieldHeight !== null) {
1583
+ // A dragged height wins over the CSS ceiling, which is there to bound the
1584
+ // field's own growing rather than to overrule the person.
1585
+ input.style.maxHeight = "none";
1586
+ input.style.height = Math.min(fieldHeight, fieldDragMax()) + "px";
1587
+ return;
1588
+ }
1589
+ input.style.maxHeight = "";
1590
+ // Reset first so the box can shrink as well as grow, then clamp.
1591
+ input.style.height = "auto";
1592
+ input.style.height = Math.min(Math.max(input.scrollHeight, FIELD_MIN), fieldGrowMax()) + "px";
1593
+ }
1594
+
1595
+ function setFieldHeight(px) {
1596
+ fieldHeight = px === null ? null : Math.round(Math.min(Math.max(px, FIELD_MIN), fieldDragMax()));
1597
+ try {
1598
+ if (fieldHeight === null) localStorage.removeItem(FIELD_KEY);
1599
+ else localStorage.setItem(FIELD_KEY, String(fieldHeight));
1600
+ } catch { /* nothing to do: the height still applies for this page */ }
1601
+ autosize();
1602
+ }
1603
+
1604
+ const grip = document.getElementById("grip");
1605
+ grip.addEventListener("pointerdown", (e) => {
1606
+ // Only the primary button, and never a second drag inside the first.
1607
+ if (e.button !== 0) return;
1608
+ e.preventDefault();
1609
+ const startY = e.clientY;
1610
+ const startHeight = input.getBoundingClientRect().height;
1611
+ // Capture is the nicety; the window listeners below are what make the drag
1612
+ // work. The strip is twelve pixels tall, and a pointer moving faster than
1613
+ // the box can follow leaves it within a frame.
1614
+ try { grip.setPointerCapture(e.pointerId); } catch { /* no live pointer to capture */ }
1615
+ grip.classList.add("dragging");
1616
+ document.body.classList.add("resizing");
1617
+
1618
+ // Up is taller: the box grows from its top edge, which is the edge in hand.
1619
+ const move = (ev) => setFieldHeight(startHeight + (startY - ev.clientY));
1620
+ const done = () => {
1621
+ window.removeEventListener("pointermove", move);
1622
+ window.removeEventListener("pointerup", done);
1623
+ window.removeEventListener("pointercancel", done);
1624
+ grip.classList.remove("dragging");
1625
+ document.body.classList.remove("resizing");
1626
+ };
1627
+ window.addEventListener("pointermove", move);
1628
+ window.addEventListener("pointerup", done);
1629
+ window.addEventListener("pointercancel", done);
1630
+ });
1631
+ // Back to sizing itself. A drag is easy to do by accident on a trackpad, and
1632
+ // without this the only way back is to guess the original height.
1633
+ grip.addEventListener("dblclick", () => setFieldHeight(null));
1634
+ // A dragged height is kept, but never past the ceiling of a window that has
1635
+ // since been made shorter.
1636
+ window.addEventListener("resize", () => autosize());
1637
+ autosize();
1638
+
1639
+ // --- the mention chip under the text ------------------------------------
1640
+
1641
+
1642
+ stopBtn.addEventListener("click", () => void api("/interrupt", { session: activeId }));
1643
+ // Straight into a session, in the tree already open. Where it runs is asked on
1644
+ // the composer instead of on the way in: the project on screen is the answer
1645
+ // almost every time, and a dialog in front of every new session charges the
1646
+ // common case for the rare one.
1647
+ document.getElementById("new").addEventListener("click", () => void switchTo({ action: "new" }));
1648
+ /**
1649
+ * The sidebar as a drawer, on a phone.
1650
+ *
1651
+ * Open from the burger, closed from the scrim, the mark in its own corner,
1652
+ * Escape, or by choosing anything in it: a drawer that stayed over the
1653
+ * transcript after a session was picked hid the very thing that was picked.
1654
+ */
1655
+ const sidebar = document.querySelector("body > aside");
1656
+ function setNavOpen(open) {
1657
+ sidebar.classList.toggle("open", open);
1658
+ document.body.classList.toggle("nav-open", open);
1659
+ }
1660
+ function closeNav() {
1661
+ if (sidebar.classList.contains("open")) setNavOpen(false);
1662
+ }
1663
+ document.getElementById("burger").addEventListener("click", () =>
1664
+ setNavOpen(!sidebar.classList.contains("open")));
1665
+ document.getElementById("nav-close").addEventListener("click", () => setNavOpen(false));
1666
+ document.getElementById("scrim").addEventListener("click", () => setNavOpen(false));
1667
+ sidebar.addEventListener("click", (e) => {
1668
+ if (!isPhone() || e.target.closest(".more")) return;
1669
+ if (e.target.closest(".session, .nav-item, .project")) closeNav();
1670
+ });
1671
+ document.addEventListener("keydown", (e) => {
1672
+ if (e.key === "Escape" && sidebar.classList.contains("open")) closeNav();
1673
+ });
1674
+ document.getElementById("review-close").addEventListener("click", () =>
1675
+ review.classList.remove("open"));
1676
+
1677
+ // The keyboard takes the bottom of the screen and iOS does not shrink the
1678
+ // layout to match, so the composer would sit under it. The body follows the
1679
+ // visible viewport instead, on phones only.
1680
+ if (window.visualViewport) {
1681
+ const followKeyboard = () => {
1682
+ if (!isPhone()) {
1683
+ document.body.style.height = "";
1684
+ return;
1685
+ }
1686
+ document.body.style.height = Math.round(window.visualViewport.height) + "px";
1687
+ window.scrollTo(0, 0);
1688
+ };
1689
+ window.visualViewport.addEventListener("resize", followKeyboard);
1690
+ window.addEventListener("resize", followKeyboard);
1691
+ }
1692
+
1693
+ `;
1694
+ //# sourceMappingURL=composer.js.map