@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,7 @@
1
+ /**
2
+ * The menus that pick something: provider marks and logos, and the model,
3
+ * mode, effort and workspace pickers built on one shared picker.
4
+ *
5
+ * One part of the client script; see ./index.ts for how the parts join.
6
+ */
7
+ export declare const PICKERS: string;
@@ -0,0 +1,624 @@
1
+ /**
2
+ * The menus that pick something: provider marks and logos, and the model,
3
+ * mode, effort and workspace pickers built on one shared picker.
4
+ *
5
+ * One part of the client script; see ./index.ts for how the parts join.
6
+ */
7
+ export const PICKERS = String.raw `
8
+ // --- pickers ---------------------------------------------------------------
9
+
10
+ /**
11
+ * A dropdown built from ordinary elements.
12
+ *
13
+ * One open at a time, closes on outside click and on Escape, and reports the
14
+ * chosen value. It exists because a native select draws its popup and its
15
+ * focus ring with the operating system's chrome, which no stylesheet reaches.
16
+ */
17
+ /**
18
+ * The mark for one model vendor, for the rows of the model menu.
19
+ *
20
+ * The same arrangement the connector list uses, for the same reason: the
21
+ * vendor's own icon when there is one, a lettered tile when there is not, and
22
+ * no third-party request from this page either way — the server fetches the
23
+ * icon once and serves it from this origin, so opening the menu does not tell
24
+ * two dozen vendors that this machine looked at their name.
25
+ *
26
+ * The resolution happens once per vendor rather than once per row. A vendor
27
+ * with four hundred models would otherwise decode the same image four hundred
28
+ * times and measure it four hundred times, on every repaint of the list.
29
+ */
30
+ const providerMarks = new Map();
31
+
32
+ const providerIconUrl = (id) => q("/provider-icon?id=" + encodeURIComponent(id));
33
+
34
+ /** What is known about a vendor's icon, and who is waiting to hear. */
35
+ function providerIconState(id) {
36
+ let state = providerMarks.get(id);
37
+ if (state) return state;
38
+ state = { status: "pending", invert: false, filled: false, waiting: [] };
39
+ providerMarks.set(id, state);
40
+
41
+ const probe = new Image();
42
+ probe.addEventListener("load", () => {
43
+ state.status = "ok";
44
+ const profile = markProfile(probe);
45
+ state.invert = profile.invert;
46
+ state.filled = profile.filled;
47
+ const waiting = state.waiting.splice(0);
48
+ for (const swap of waiting) swap();
49
+ });
50
+ // A vendor with no icon is ordinary: local servers have no site to ask, and
51
+ // plenty of sites answer nothing usable. The tile is a complete answer.
52
+ probe.addEventListener("error", () => {
53
+ state.status = "none";
54
+ state.waiting.length = 0;
55
+ });
56
+ probe.src = providerIconUrl(id);
57
+ return state;
58
+ }
59
+
60
+ /** A stable hue per vendor, so a tile is the same colour on every repaint. */
61
+ function providerMono(id, label, extra) {
62
+ let hash = 0;
63
+ for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
64
+ const mono = el("span", "vmono" + (extra ? " " + extra : ""), (label || id || "?").trim().charAt(0));
65
+ mono.style.background = "hsl(" + hash + " 42% 42%)";
66
+ return mono;
67
+ }
68
+
69
+ function providerLogo(id, state, extra) {
70
+ const img = new Image();
71
+ // A mark that owns its whole box keeps it; one that is bare ink is inset,
72
+ // so a wide glyph with no margin of its own does not read as bigger and
73
+ // off-line next to a tile that fills the same square.
74
+ img.className =
75
+ "vmark" +
76
+ (state.filled ? " filled" : "") +
77
+ (state.invert ? " invert-dark" : "") +
78
+ (extra ? " " + extra : "");
79
+ img.alt = "";
80
+ img.src = providerIconUrl(id);
81
+ return img;
82
+ }
83
+
84
+ /**
85
+ * A vendor's mark, ready to put in a row.
86
+ *
87
+ * Returns something to draw now, not a promise: the menu repaints on every
88
+ * keystroke and cannot wait on the network. A tile goes in, and if the icon
89
+ * turns out to exist it takes the tile's place — but only if that tile is
90
+ * still on the page, since the row it was made for may already be gone.
91
+ */
92
+ function providerMark(id, label, extra) {
93
+ // Ours is drawn from the same paths as the application icon rather than
94
+ // fetched from our own site: it is the one mark that has to be there
95
+ // offline, on the row that is being recommended.
96
+ if (id === "official") return svgIcon("oharness", "vglyph" + (extra ? " " + extra : ""));
97
+
98
+ const state = providerIconState(id);
99
+ if (state.status === "ok") return providerLogo(id, state, extra);
100
+
101
+ const mono = providerMono(id, label, extra);
102
+ if (state.status === "pending") {
103
+ state.waiting.push(() => {
104
+ if (mono.isConnected) mono.replaceWith(providerLogo(id, state, extra));
105
+ });
106
+ }
107
+ return mono;
108
+ }
109
+
110
+ /**
111
+ * The model id, shortened to what the bar has room for: everything after the
112
+ * last slash.
113
+ *
114
+ * The rule used to be a length test — over 28 characters keep the tail, under
115
+ * it keep the whole thing — which is how "~deepseek/deepseek-v4-flash" reached
116
+ * the bar at 27 characters, naming its vendor twice and then being cut
117
+ * mid-word by the ellipsis anyway. Length was the wrong question: what to drop
118
+ * does not depend on how long the string happens to be.
119
+ *
120
+ * What to drop is the namespace, every time. The vendor's mark is drawn
121
+ * immediately to the left of this, so the prefix is a picture repeated in
122
+ * words — and an aggregator's id nests, which makes the prefix the longer half
123
+ * of a name whose distinguishing half is at the end. The whole id stays in the
124
+ * title attribute and in the menu.
125
+ */
126
+ function shortModel(full) {
127
+ const cut = full.lastIndexOf("/");
128
+ return cut < 0 ? full : full.slice(cut + 1);
129
+ }
130
+
131
+ function makePicker(id, onPick, extra, opts = {}) {
132
+ const root = document.getElementById(id);
133
+ const button = root.querySelector(".picker-btn");
134
+ const label = root.querySelector(".picker-value");
135
+ const menu = root.querySelector(".menu");
136
+
137
+ // The menu owns a search box and a scrolling body. Built once here rather
138
+ // than in the markup so every picker gets the same behaviour.
139
+ const searchWrap = el("div", "menu-search");
140
+ const search = document.createElement("input");
141
+ search.type = "text";
142
+ search.placeholder = t("menu.search");
143
+ search.autocomplete = "off";
144
+ search.spellcheck = false;
145
+ searchWrap.appendChild(search);
146
+ const body = el("div", "menu-body");
147
+ // A fixed footer, not the end of the scrolling list: appended to the body it
148
+ // sat below four hundred models and could never be reached.
149
+ const footer = el("div", "menu-footer");
150
+ menu.replaceChildren(searchWrap, body, footer);
151
+
152
+ let options = [];
153
+ let value = null;
154
+
155
+ let query = "";
156
+ let cursor = 0;
157
+ /** Groups the user has folded away; survives repainting. */
158
+ const collapsed = new Set();
159
+ /**
160
+ * Groups folded while searching. Kept apart from the browsing folds: a
161
+ * search opens every vendor, because a match behind a closed header looks
162
+ * like no result — but a header that then refuses to close is a control
163
+ * that does nothing, and OpenRouter's twenty matches can bury the one vendor
164
+ * being looked for. Cleared when the query changes, so each search starts
165
+ * with everything it found on screen.
166
+ */
167
+ const searchFolded = new Set();
168
+ const folded = (group) => (query ? searchFolded.has(group) : collapsed.has(group));
169
+
170
+ /** The options a keystroke can land on: what is actually on screen. */
171
+ const visible = () => matches().filter((option) => !option.group || !folded(option.group));
172
+
173
+ const close = () => {
174
+ menu.hidden = true;
175
+ root.classList.remove("open");
176
+ query = "";
177
+ searchFolded.clear();
178
+ };
179
+
180
+ const matches = () => options.filter((option) => matchesQuery(option, query));
181
+
182
+ const paint = () => {
183
+ const list = matches();
184
+
185
+ const shown = list.filter((option) => !option.group || !folded(option.group));
186
+ if (cursor >= shown.length) cursor = Math.max(0, shown.length - 1);
187
+
188
+ // Only the body is rebuilt: replacing the menu itself would take the
189
+ // search field out from under the caret on the first keystroke.
190
+ body.replaceChildren();
191
+
192
+ const counts = new Map();
193
+ for (const option of list) {
194
+ if (option.group) counts.set(option.group, (counts.get(option.group) || 0) + 1);
195
+ }
196
+
197
+ let lastGroup = null;
198
+ let visibleIndex = 0;
199
+ for (const option of list) {
200
+ // A flat list of four hundred models across a dozen vendors is
201
+ // unreadable; the vendor is the first thing anyone narrows by.
202
+ if (option.group && option.group !== lastGroup) {
203
+ lastGroup = option.group;
204
+ const group = option.group;
205
+ const header = el("button", "menu-group" + (folded(group) ? "" : " open"));
206
+ header.type = "button";
207
+ header.appendChild(el("span", "chev"));
208
+ // The vendor's mark leads its header, so a folded list reads as a
209
+ // column of vendors rather than as a column of lowercase words.
210
+ const groupMark = api.groupMark && api.groupMark(group);
211
+ if (groupMark) header.appendChild(groupMark);
212
+ header.appendChild(el("span", "g-name", group));
213
+ // A vendor may be worth marking out — the official plan is the one
214
+ // this project stands behind, and a group header is where someone
215
+ // scanning the list actually looks.
216
+ const tag = api.groupTag && api.groupTag(group);
217
+ if (tag) header.appendChild(el("span", "g-tag", tag));
218
+ header.appendChild(el("span", "g-count", String(counts.get(group) || 0)));
219
+ header.addEventListener("click", (e) => {
220
+ e.stopPropagation();
221
+ const set = query ? searchFolded : collapsed;
222
+ if (set.has(group)) set.delete(group);
223
+ else set.add(group);
224
+ cursor = 0;
225
+ paint();
226
+ });
227
+ body.appendChild(header);
228
+ }
229
+
230
+ if (option.group && folded(option.group)) continue;
231
+ const i = visibleIndex++;
232
+ const item = el(
233
+ "button",
234
+ "menu-item" + (option.value === value ? " on" : "") + (i === cursor ? " cursor" : ""),
235
+ );
236
+ item.type = "button";
237
+ item.appendChild(el("span", "tick", "✓"));
238
+ // Between the tick and the name: the row is "is this the one" then
239
+ // "whose is it" then "which model", which is the order it is read in.
240
+ const mark = option.mark && option.mark();
241
+ if (mark) item.appendChild(mark);
242
+ item.appendChild(el("span", "menu-label", option.label));
243
+ // Beside the name rather than out at the right margin where sub sits:
244
+ // this qualifies the model it is attached to, and a badge floated away
245
+ // from its name is a badge the eye has to pair up again on every row.
246
+ if (option.tag) item.appendChild(el("span", "m-tag", option.tag));
247
+ if (option.sub) item.appendChild(el("span", "sub", option.sub));
248
+ item.addEventListener("click", () => {
249
+ close();
250
+ if (option.value !== value) onPick(option.value);
251
+ });
252
+ body.appendChild(item);
253
+ }
254
+
255
+ if (!list.length) body.appendChild(el("div", "menu-empty", t("menu.none")));
256
+ };
257
+
258
+ const open = () => {
259
+ for (const other of document.querySelectorAll(".picker.open")) {
260
+ if (other !== root) {
261
+ other.classList.remove("open");
262
+ other.querySelector(".menu").hidden = true;
263
+ }
264
+ }
265
+ query = "";
266
+ search.value = "";
267
+
268
+ // Everything folded except the vendor holding the current model: opening
269
+ // onto a dozen closed headers hides the one answer the user already has.
270
+ // A picker may name groups that always open as well — the model picker's
271
+ // own plan, which is where someone looking for a model should land first.
272
+ collapsed.clear();
273
+ const current = options.find((option) => option.value === value);
274
+ const alwaysOpen = new Set(opts.openGroups || []);
275
+ for (const option of options) {
276
+ if (!option.group || alwaysOpen.has(option.group)) continue;
277
+ if (option.group !== (current && current.group)) collapsed.add(option.group);
278
+ }
279
+ cursor = Math.max(0, visible().findIndex((option) => option.value === value));
280
+ // Searching costs a row of chrome and is pointless below a handful of
281
+ // entries, so the five-item mode picker does not get one.
282
+ searchWrap.hidden = options.length < 8;
283
+ footer.replaceChildren();
284
+ if (extra) footer.appendChild(extra());
285
+ footer.hidden = !extra;
286
+ paint();
287
+ menu.hidden = false;
288
+ root.classList.add("open");
289
+ // Opened onto the model in use rather than onto the top of a vendor with
290
+ // four hundred entries.
291
+ body.scrollTop = 0;
292
+ const on = body.querySelector(".menu-item.on");
293
+ if (on) on.scrollIntoView({ block: "nearest" });
294
+ if (!searchWrap.hidden) search.focus();
295
+ };
296
+
297
+ search.addEventListener("input", () => {
298
+ if (search.value !== query) searchFolded.clear();
299
+ query = search.value;
300
+ cursor = 0;
301
+ paint();
302
+ // The body keeps its scroll offset across a repaint. Searching from
303
+ // halfway down a long vendor therefore left the first results — often the
304
+ // vendor whose name was just typed — above the visible window, which reads
305
+ // as "that vendor has no models" when its two rows are simply out of view.
306
+ body.scrollTop = 0;
307
+ });
308
+
309
+ search.addEventListener("click", (e) => e.stopPropagation());
310
+
311
+ search.addEventListener("keydown", (e) => {
312
+ const list = visible();
313
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
314
+ e.preventDefault();
315
+ if (!list.length) return;
316
+ cursor = (cursor + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length;
317
+ paint();
318
+ const active = body.querySelector(".cursor");
319
+ if (active) active.scrollIntoView({ block: "nearest" });
320
+ return;
321
+ }
322
+ if (e.key === "Enter") {
323
+ e.preventDefault();
324
+ const picked = list[cursor];
325
+ close();
326
+ if (picked && picked.value !== value) onPick(picked.value);
327
+ }
328
+ });
329
+
330
+ button.addEventListener("click", (e) => {
331
+ e.stopPropagation();
332
+ menu.hidden ? open() : close();
333
+ });
334
+ document.addEventListener("click", close);
335
+ document.addEventListener("keydown", (e) => {
336
+ if (e.key === "Escape") close();
337
+ });
338
+
339
+ const api = {
340
+ /** Optional: a badge for a group header, e.g. the recommended vendor. */
341
+ groupTag: null,
342
+ /** Optional: a mark for a group header, e.g. the vendor's own icon. */
343
+ groupMark: null,
344
+ set(list, current) {
345
+ options = list;
346
+ value = current;
347
+ const match = list.find((option) => option.value === current);
348
+ // The mark travels with the choice onto the bar. The label there is a
349
+ // truncated id — the tail of google/gemini-3.6-flash:batch says nothing
350
+ // about who is being paid for it, and that is the half of the answer
351
+ // somebody glances down to check.
352
+ const stale = button.querySelector(".picker-mark");
353
+ if (stale) stale.remove();
354
+ const mark = match && match.mark && match.mark("picker-mark");
355
+ if (mark) button.insertBefore(mark, label);
356
+ const full = match ? match.label : current || "—";
357
+ label.textContent = shortModel(full);
358
+ label.title = full;
359
+ },
360
+ get value() {
361
+ return value;
362
+ },
363
+ };
364
+ return api;
365
+ }
366
+
367
+ // Drawn glyphs rather than vendor tiles: a mode is chrome, not a party being
368
+ // paid, so it gets the bare stroke every other menu icon has.
369
+ const modeMark = (icon) => (extra) => svgIcon(icon, "mode-mark" + (extra ? " " + extra : ""));
370
+
371
+ const MODES = [
372
+ { value: "default", label: "Manual", sub: "ask first", mark: modeMark("modeAsk") },
373
+ { value: "acceptEdits", label: "Accept edits", sub: "edits pass", mark: modeMark("modeEdits") },
374
+ { value: "plan", label: "Plan", sub: "read-only", mark: modeMark("modePlan") },
375
+ { value: "auto", label: "Auto", sub: "routine passes", mark: modeMark("modeAuto") },
376
+ { value: "bypassPermissions", label: "Bypass", sub: "all pass", mark: modeMark("modeBypass") },
377
+ ];
378
+
379
+ const modelPicker = makePicker(
380
+ "model-picker",
381
+ (id) => {
382
+ // A vendor row rather than a model: there is nothing to switch to until it
383
+ // has credentials or a discovery pass. Both are one action, so both happen
384
+ // here rather than in a panel the user then has to find the vendor in
385
+ // again — a key gets a dialog over the menu, and a vendor that already has
386
+ // credentials just gets asked for its list.
387
+ if (String(id).indexOf("setup:") === 0) {
388
+ const providerId = String(id).slice(6);
389
+ const provider = providers.find((p) => p.id === providerId);
390
+ if (provider && provider.configured) return void discoverNow(providerId);
391
+ return void openConnect(providerId);
392
+ }
393
+
394
+ // A model whose vendor has no credentials would 401 on the first turn,
395
+ // and the picker is where the choice was made — so the key is asked for
396
+ // here and the switch happens once it lands.
397
+ const owner = providers.find((p) => p.id === (modelOptions.find((o) => o.value === id) || {}).group);
398
+ if (owner && !owner.configured) return void openConnect(owner.id, id);
399
+
400
+ // The whole claim of the project is that this switch is cheap, so how
401
+ // often it is actually made — and to what — is the one number worth having.
402
+ track("model_switched", { model: id, via: "picker" });
403
+ void api("/model", { session: activeId, id }).then(applyState);
404
+ },
405
+ () => {
406
+ // The catalog ships providers, not model names — vendors rename faster
407
+ // than this package publishes. So the picker offers the way to go ask.
408
+ const link = el("button", "menu-more");
409
+ link.type = "button";
410
+ // Drawn at the same weight and size as every other icon; the Unicode gear
411
+ // arrived from whatever font had it and sat small next to the label.
412
+ link.appendChild(svgIcon("settings"));
413
+ link.appendChild(el("span", null, t("menu.manage")));
414
+ link.addEventListener("click", () => {
415
+ setNav("nav-models");
416
+ // Opened for the list itself, so nothing is singled out.
417
+ focusProvider = null;
418
+ void showModels();
419
+ });
420
+ return link;
421
+ },
422
+ { openGroups: ["official"] },
423
+ );
424
+ const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
425
+
426
+ /**
427
+ * Reasoning effort for the session on screen.
428
+ *
429
+ * Not built on makePicker: that draws a searchable list, and effort is one
430
+ * quantity on a scale. The stops are whatever the current model accepts, plus
431
+ * "off" where the model can be asked not to think, so a tier the model would
432
+ * clamp away is never offered in the first place.
433
+ *
434
+ * What the server reports is the tier the next request will carry. A pick is
435
+ * kept as picked on the session, so moving to a model without "max" shows the
436
+ * tier it lands on, and moving back shows "max" again.
437
+ */
438
+ const effortPicker = (() => {
439
+ const root = document.getElementById("effort-picker");
440
+ const button = root.querySelector(".picker-btn");
441
+ const label = root.querySelector(".picker-value");
442
+ const menu = root.querySelector(".menu");
443
+ let stops = [];
444
+ let value = null;
445
+
446
+ const name = (stop) => t("effort." + stop);
447
+ const close = () => {
448
+ root.classList.remove("open");
449
+ menu.hidden = true;
450
+ };
451
+
452
+ const pick = (next) => {
453
+ if (!next || next === value) return;
454
+ value = next;
455
+ paint(true);
456
+ track("effort_changed", { effort: next });
457
+ void api("/effort", { session: activeId, effort: next }).then(applyState);
458
+ };
459
+
460
+ function paint(refocus) {
461
+ const index = Math.max(0, stops.indexOf(value));
462
+ label.textContent = value ? t("effort.short." + value) : "…";
463
+ button.title = t("effort.tooltip") + ": " + (value ? name(value) : "");
464
+ if (menu.hidden && !refocus) return;
465
+
466
+ menu.replaceChildren();
467
+ const head = el("div", "effort-head");
468
+ head.appendChild(el("span", "effort-title", t("effort.title")));
469
+ head.appendChild(el("span", "effort-current", value ? name(value) : ""));
470
+ menu.appendChild(head);
471
+ menu.appendChild(el("div", "effort-desc", value ? t("effort.desc." + value) : ""));
472
+
473
+ const ends = el("div", "effort-ends");
474
+ ends.appendChild(el("span", null, t("effort.faster")));
475
+ ends.appendChild(el("span", null, t("effort.smarter")));
476
+ menu.appendChild(ends);
477
+
478
+ const rail = el("div", "effort-rail");
479
+ rail.setAttribute("role", "radiogroup");
480
+ rail.setAttribute("aria-label", t("effort.tooltip"));
481
+ const fill = el("span", "effort-fill");
482
+ const share = stops.length > 1 ? index / (stops.length - 1) : 0;
483
+ // From the rail's left edge to the far side of the chosen stop.
484
+ fill.style.width = "calc((100% - var(--stop)) * " + share + " + var(--stop))";
485
+ rail.appendChild(fill);
486
+
487
+ const names = el("div", "effort-names");
488
+ stops.forEach((stop, i) => {
489
+ const on = i === index;
490
+ const dot = el("button", "effort-stop" + (on ? " on" : ""));
491
+ dot.type = "button";
492
+ dot.setAttribute("role", "radio");
493
+ dot.setAttribute("aria-checked", on ? "true" : "false");
494
+ dot.setAttribute("aria-label", name(stop));
495
+ dot.tabIndex = on ? 0 : -1;
496
+ dot.title = name(stop);
497
+ dot.appendChild(el("span", on ? "effort-knob" : "effort-dot"));
498
+ dot.addEventListener("click", () => pick(stop));
499
+ dot.addEventListener("keydown", (e) => {
500
+ const step = e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : 0;
501
+ if (!step) return;
502
+ e.preventDefault();
503
+ pick(stops[Math.min(stops.length - 1, Math.max(0, index + step))]);
504
+ });
505
+ rail.appendChild(dot);
506
+ names.appendChild(el("span", "effort-name" + (on ? " on" : ""), t("effort.short." + stop)));
507
+ });
508
+ menu.appendChild(rail);
509
+ menu.appendChild(names);
510
+
511
+ if (refocus) {
512
+ const active = rail.querySelector(".effort-stop.on");
513
+ if (active) active.focus();
514
+ }
515
+ }
516
+
517
+ const open = () => {
518
+ for (const other of document.querySelectorAll(".picker.open")) {
519
+ if (other !== root) {
520
+ other.classList.remove("open");
521
+ other.querySelector(".menu").hidden = true;
522
+ }
523
+ }
524
+ root.classList.add("open");
525
+ menu.hidden = false;
526
+ paint(true);
527
+ };
528
+
529
+ button.addEventListener("click", (e) => {
530
+ e.stopPropagation();
531
+ menu.hidden ? open() : close();
532
+ });
533
+ menu.addEventListener("click", (e) => e.stopPropagation());
534
+ document.addEventListener("click", close);
535
+ document.addEventListener("keydown", (e) => {
536
+ if (e.key === "Escape" && !menu.hidden) {
537
+ close();
538
+ button.focus();
539
+ }
540
+ });
541
+
542
+ return {
543
+ set(state) {
544
+ const levels = Array.isArray(state.effortLevels) ? state.effortLevels : [];
545
+ // A model with no tiers has nothing to set; hiding beats a control that
546
+ // does nothing.
547
+ root.hidden = levels.length === 0 || !state.effort;
548
+ if (root.hidden) {
549
+ close();
550
+ return;
551
+ }
552
+ stops = (state.effortOff ? ["off"] : []).concat(levels);
553
+ value = state.effort;
554
+ paint(false);
555
+ },
556
+ };
557
+ })();
558
+
559
+ /**
560
+ * Which tree the session about to start will run in.
561
+ *
562
+ * Offered on the composer rather than in a dialog on the way in: the answer is
563
+ * usually the project already open, and the one time it is not, it is a
564
+ * property of the task being typed. Every directory opened here is on the
565
+ * list, and the last row goes and finds one that is not.
566
+ */
567
+ const workspacePicker = makePicker(
568
+ "workspace-picker",
569
+ (path) => void reroot(path),
570
+ () => {
571
+ const link = el("button", "menu-more");
572
+ link.type = "button";
573
+ link.appendChild(svgIcon("folder"));
574
+ link.appendChild(el("span", null, t("projects.other")));
575
+ link.addEventListener("click", () => void pickFolder((path) => reroot(path)));
576
+ return link;
577
+ },
578
+ );
579
+
580
+ const workspaceRow = document.getElementById("workspace-picker");
581
+
582
+ /**
583
+ * Move an unstarted session to another tree.
584
+ *
585
+ * A transcript belongs to the directory it was recorded in, so this is not a
586
+ * session changing address: it is a new one in the right place, and the empty
587
+ * one it replaces is thrown away rather than filed in the sidebar for every
588
+ * change of mind.
589
+ */
590
+ async function reroot(path) {
591
+ if (!path || path === currentCwd) return;
592
+ const leaving = activeId;
593
+ const record = panes.get(leaving);
594
+ const bare = record && (record.node.childElementCount === 0 || record.node.contains(emptyEl));
595
+
596
+ await switchTo({ action: "new", cwd: path });
597
+ if (bare && leaving && leaving !== activeId) {
598
+ const gone = panes.get(leaving);
599
+ if (gone) gone.node.remove();
600
+ panes.delete(leaving);
601
+ await api("/session", { action: "delete", id: leaving });
602
+ paintTabs();
603
+ }
604
+ await loadProjects();
605
+ await loadSessions();
606
+ input.focus();
607
+ }
608
+
609
+ /**
610
+ * A folder from this machine, by whatever means this machine has.
611
+ *
612
+ * The system dialog is what "choose a folder" means, and the browser cannot
613
+ * open one that reports a path. A host without a dialog — headless, remote, a
614
+ * stripped-down image — browses the server's own directories instead.
615
+ */
616
+ async function pickFolder(onPick) {
617
+ const picked = await api("/pick", {}, 5 * 60_000);
618
+ if (picked && picked.path) return void onPick(picked.path);
619
+ if (picked && picked.cancelled) return;
620
+ addNotice(t("projects.pickFailed"));
621
+ openBrowser(undefined, { onPick });
622
+ }
623
+ `;
624
+ //# sourceMappingURL=pickers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pickers.js","sourceRoot":"","sources":["../../../src/page/client/pickers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwmBhC,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The routines page, the settings page, and the permission rules page.
3
+ *
4
+ * One part of the client script; see ./index.ts for how the parts join.
5
+ */
6
+ export declare const ROUTINES: string;