@oharness/server 0.9.3 → 0.11.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 (93) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +500 -64
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/main.js +4 -0
  9. package/dist/main.js.map +1 -1
  10. package/dist/model-order.js +1 -0
  11. package/dist/model-order.js.map +1 -1
  12. package/dist/page/client/account.js +570 -27
  13. package/dist/page/client/account.js.map +1 -1
  14. package/dist/page/client/base.js +55 -12
  15. package/dist/page/client/base.js.map +1 -1
  16. package/dist/page/client/browser.js +1 -1
  17. package/dist/page/client/composer.js +802 -59
  18. package/dist/page/client/composer.js.map +1 -1
  19. package/dist/page/client/computer.d.ts +27 -0
  20. package/dist/page/client/computer.js +358 -0
  21. package/dist/page/client/computer.js.map +1 -0
  22. package/dist/page/client/dialogs.js +168 -6
  23. package/dist/page/client/dialogs.js.map +1 -1
  24. package/dist/page/client/events.js +119 -9
  25. package/dist/page/client/events.js.map +1 -1
  26. package/dist/page/client/files.js +86 -1
  27. package/dist/page/client/files.js.map +1 -1
  28. package/dist/page/client/index.js +8 -0
  29. package/dist/page/client/index.js.map +1 -1
  30. package/dist/page/client/panels.js +14 -10
  31. package/dist/page/client/panels.js.map +1 -1
  32. package/dist/page/client/ripple.d.ts +13 -0
  33. package/dist/page/client/ripple.js +247 -0
  34. package/dist/page/client/ripple.js.map +1 -0
  35. package/dist/page/client/routines.js +432 -45
  36. package/dist/page/client/routines.js.map +1 -1
  37. package/dist/page/client/shell.js +386 -104
  38. package/dist/page/client/shell.js.map +1 -1
  39. package/dist/page/client/state.js +278 -19
  40. package/dist/page/client/state.js.map +1 -1
  41. package/dist/page/client/tasks.d.ts +8 -0
  42. package/dist/page/client/tasks.js +293 -0
  43. package/dist/page/client/tasks.js.map +1 -0
  44. package/dist/page/client/terminal.js +34 -7
  45. package/dist/page/client/terminal.js.map +1 -1
  46. package/dist/page/client/theme.d.ts +17 -0
  47. package/dist/page/client/theme.js +365 -0
  48. package/dist/page/client/theme.js.map +1 -0
  49. package/dist/page/client/transcript.js +196 -8
  50. package/dist/page/client/transcript.js.map +1 -1
  51. package/dist/page/client/usage.js +1 -11
  52. package/dist/page/client/usage.js.map +1 -1
  53. package/dist/page/style.js +745 -76
  54. package/dist/page/style.js.map +1 -1
  55. package/dist/page.js +94 -16
  56. package/dist/page.js.map +1 -1
  57. package/dist/prefs.d.ts +31 -0
  58. package/dist/prefs.js +13 -1
  59. package/dist/prefs.js.map +1 -1
  60. package/dist/remote-crypto.d.ts +78 -0
  61. package/dist/remote-crypto.js +216 -0
  62. package/dist/remote-crypto.js.map +1 -0
  63. package/dist/remote.d.ts +103 -0
  64. package/dist/remote.js +498 -0
  65. package/dist/remote.js.map +1 -0
  66. package/dist/server.d.ts +70 -0
  67. package/dist/server.js +1738 -73
  68. package/dist/server.js.map +1 -1
  69. package/dist/starters.d.ts +92 -0
  70. package/dist/starters.js +213 -0
  71. package/dist/starters.js.map +1 -0
  72. package/dist/suggest.d.ts +42 -0
  73. package/dist/suggest.js +215 -0
  74. package/dist/suggest.js.map +1 -0
  75. package/dist/terminal.d.ts +28 -2
  76. package/dist/terminal.js +45 -3
  77. package/dist/terminal.js.map +1 -1
  78. package/dist/theme-assets.d.ts +39 -0
  79. package/dist/theme-assets.js +109 -0
  80. package/dist/theme-assets.js.map +1 -0
  81. package/dist/theme-store.d.ts +50 -0
  82. package/dist/theme-store.js +124 -0
  83. package/dist/theme-store.js.map +1 -0
  84. package/dist/theme-tools.d.ts +25 -0
  85. package/dist/theme-tools.js +243 -0
  86. package/dist/theme-tools.js.map +1 -0
  87. package/dist/turn-shots.d.ts +43 -0
  88. package/dist/turn-shots.js +80 -0
  89. package/dist/turn-shots.js.map +1 -0
  90. package/dist/user-theme.d.ts +143 -0
  91. package/dist/user-theme.js +510 -0
  92. package/dist/user-theme.js.map +1 -0
  93. package/package.json +2 -2
@@ -8,6 +8,11 @@ export const SHELL = String.raw `
8
8
 
9
9
  const projectBtn = document.getElementById("project");
10
10
  let projects = [];
11
+ /** The server's home directory, which the picker writes as ~. */
12
+ let projectHome = "";
13
+ /** Session directories that are worktrees, each naming the project it came from. */
14
+ let worktrees = [];
15
+ const worktreeAt = (cwd) => worktrees.find((one) => one.path === cwd);
11
16
 
12
17
  /** The last path segment, which is what the directory is called. */
13
18
  function projectName(dir) {
@@ -25,6 +30,7 @@ function paintProject() {
25
30
  // it too, or it goes on advertising the tree the user just left.
26
31
  const cwdEl = document.getElementById("empty-cwd");
27
32
  if (cwdEl && active) cwdEl.textContent = active.name;
33
+ if (active) void paintStarters(active.path);
28
34
  // A directory added while the chip was open belongs on its list.
29
35
  refreshWorkspace();
30
36
  }
@@ -50,6 +56,8 @@ function repaintPanel() {
50
56
  async function loadProjects() {
51
57
  const body = await getJson("/projects");
52
58
  projects = body.projects || [];
59
+ projectHome = body.home || "";
60
+ worktrees = body.worktrees || [];
53
61
  projectOrder = body.order || [];
54
62
  paintProject();
55
63
  // A project's pin and its place decide how the session list is grouped, so
@@ -59,9 +67,9 @@ async function loadProjects() {
59
67
  }
60
68
 
61
69
  async function renameProjectUi(cwd, name) {
62
- const next = window.prompt(t("projects.renamePrompt"), name);
63
- if (!next || next.trim() === name) return;
64
- const body = await api("/project", { path: cwd, rename: next.trim() });
70
+ const next = await askText({ title: t("projects.rename"), body: t("projects.renamePrompt"), value: name, ok: t("dialog.save") });
71
+ if (!next || next === name) return;
72
+ const body = await api("/project", { path: cwd, rename: next });
65
73
  if (body && body.error) return void addNotice(body.error, "err");
66
74
  addNotice(t("projects.renamed", { name: body.project.name }));
67
75
  // Paths key tabs, local open/closed state and drag order. Reloading after
@@ -76,73 +84,124 @@ function exportProjectUi(cwd) {
76
84
  link.click();
77
85
  }
78
86
 
87
+ /** A project's directory as people say it: under home, from ~. */
88
+ function projectDir(path) {
89
+ const home = projectHome.replace(/[\\/]+$/, "");
90
+ if (home && (path === home || path.startsWith(home + "/") || path.startsWith(home + "\\"))) return "~" + path.slice(home.length);
91
+ return path;
92
+ }
93
+
79
94
  function openProjectPicker() {
80
95
  const dialog = document.getElementById("project-picker");
81
96
  const field = document.getElementById("proj-search");
82
97
  const listEl = document.getElementById("proj-list");
83
98
  field.placeholder = t("projects.search");
84
99
 
85
- let first = null;
100
+ // The row menu lives in <body>, which a modal dialog makes inert: while the
101
+ // picker is open the menu is moved inside it, and put back when it closes.
102
+ const menuHome = rowMenu.parentNode;
103
+ dialog.appendChild(rowMenu);
104
+ dialog.addEventListener("close", () => {
105
+ closeRowMenu();
106
+ menuHome.appendChild(rowMenu);
107
+ }, { once: true });
108
+
109
+ /** The rows that open a project, in order, and which one Enter opens. */
110
+ let rows = [];
111
+ let active = 0;
112
+ const light = (index, scroll) => {
113
+ if (!rows.length) return;
114
+ active = (index + rows.length) % rows.length;
115
+ rows.forEach((row, i) => row.parentNode.classList.toggle("active", i === active));
116
+ if (scroll) rows[active].scrollIntoView({ block: "nearest" });
117
+ };
118
+
119
+ const menuFor = (project, more, line) => {
120
+ const items = [
121
+ menuItem(t("projects.rename"), {
122
+ key: "r",
123
+ run: () => {
124
+ dialog.close();
125
+ void renameProjectUi(project.path, project.name);
126
+ },
127
+ }),
128
+ menuItem(t("projects.export"), {
129
+ key: "e",
130
+ run: () => {
131
+ closeRowMenu();
132
+ exportProjectUi(project.path);
133
+ },
134
+ }),
135
+ ];
136
+ // Not offered for the project this window is standing in: the next
137
+ // switch calls addProject and puts it straight back, so the control
138
+ // would appear to do nothing. Nothing recorded is lost either way —
139
+ // this forgets a path, and the sessions stay where they are.
140
+ if (!project.current) {
141
+ items.push(
142
+ menuSep(),
143
+ menuItem(t("projects.forget"), {
144
+ danger: true,
145
+ run: () => {
146
+ closeRowMenu();
147
+ void api("/project", { path: project.path, remove: true }).then(async (body) => {
148
+ if (body && body.error) return void addNotice(body.error, "err");
149
+ await loadProjects();
150
+ paint();
151
+ });
152
+ },
153
+ }),
154
+ );
155
+ }
156
+ openRowMenu(more, line, items);
157
+ };
158
+
86
159
  const paint = () => {
87
160
  const query = field.value.trim().toLowerCase();
88
161
  listEl.replaceChildren();
89
- first = null;
162
+ rows = [];
163
+ let here = 0;
90
164
  for (const project of projects) {
91
165
  if (query && !(project.name + " " + project.path).toLowerCase().includes(query)) continue;
166
+ const line = el("div", "proj-row" + (project.current ? " current" : ""));
92
167
  const row = el("button", "proj-item");
93
168
  row.type = "button";
169
+ row.title = project.path;
94
170
  row.appendChild(el("span", "avatar", project.name.slice(0, 1).toUpperCase()));
95
- row.appendChild(el("span", "path", project.name));
171
+ const text = el("span", "proj-text");
172
+ text.appendChild(el("span", "path", project.name));
173
+ text.appendChild(el("span", "proj-dir", projectDir(project.path)));
174
+ row.appendChild(text);
96
175
  if (project.missing) row.appendChild(el("span", "tag", t("projects.missing")));
97
- if (project.current) row.appendChild(el("span", "muted", "✓"));
176
+ const tick = el("span", "proj-check");
177
+ if (project.current) tick.appendChild(svgIcon("check"));
178
+ row.appendChild(tick);
98
179
  row.addEventListener("click", () => {
99
180
  dialog.close();
100
181
  if (!project.current) void chooseProject(project.path);
101
182
  });
102
-
103
- const line = el("div", "proj-row");
183
+ const index = rows.length;
184
+ row.addEventListener("mousemove", () => { if (active !== index) light(index); });
104
185
  line.appendChild(row);
105
- const rename = el("button", "proj-forget", "✎");
106
- rename.type = "button";
107
- rename.title = t("projects.rename");
108
- rename.setAttribute("aria-label", t("projects.rename") + ": " + project.name);
109
- rename.addEventListener("click", (e) => {
110
- e.stopPropagation();
111
- dialog.close();
112
- void renameProjectUi(project.path, project.name);
113
- });
114
- line.appendChild(rename);
115
- const archive = el("button", "proj-forget", "↓");
116
- archive.type = "button";
117
- archive.title = t("projects.export");
118
- archive.setAttribute("aria-label", t("projects.export") + ": " + project.name);
119
- archive.addEventListener("click", (e) => {
186
+
187
+ const more = el("button", "proj-more");
188
+ more.type = "button";
189
+ more.title = t("session.more");
190
+ more.setAttribute("aria-label", t("session.more") + ": " + project.name);
191
+ more.appendChild(svgIcon("more"));
192
+ more.addEventListener("click", (e) => {
120
193
  e.stopPropagation();
121
- exportProjectUi(project.path);
194
+ if (menuRow === line) return closeRowMenu();
195
+ menuFor(project, more, line);
122
196
  });
123
- line.appendChild(archive);
124
- // Not offered for the project this window is standing in: the next
125
- // switch calls addProject and puts it straight back, so the control
126
- // would appear to do nothing. Nothing recorded is lost either way —
127
- // this forgets a path, and the sessions stay where they are.
128
- if (!project.current) {
129
- const forget = el("button", "proj-forget", "✕");
130
- forget.type = "button";
131
- forget.title = t("projects.forget");
132
- forget.setAttribute("aria-label", t("projects.forget") + ": " + project.name);
133
- forget.addEventListener("click", (e) => {
134
- e.stopPropagation();
135
- void api("/project", { path: project.path, remove: true }).then(async (body) => {
136
- if (body && body.error) return void addNotice(body.error, "err");
137
- await loadProjects();
138
- paint();
139
- });
140
- });
141
- line.appendChild(forget);
142
- }
197
+ line.appendChild(more);
143
198
  listEl.appendChild(line);
144
- first = first || row;
199
+ if (project.current && !query) here = rows.length;
200
+ rows.push(row);
145
201
  }
202
+ if (!rows.length) listEl.appendChild(el("div", "proj-empty", t("projects.none")));
203
+ // Searching starts from the first match; an empty query from where you are.
204
+ light(here, true);
146
205
  };
147
206
 
148
207
  const add = document.getElementById("proj-add");
@@ -153,20 +212,38 @@ function openProjectPicker() {
153
212
  };
154
213
 
155
214
  field.value = "";
156
- field.oninput = paint;
157
- // Typing enough of the name and pressing Enter is what a list with a search
158
- // box above it promises; without this the field swallows the key.
215
+ field.oninput = () => {
216
+ closeRowMenu();
217
+ paint();
218
+ };
219
+ // A list with a search box above it promises the keys of one: arrows move
220
+ // through what matched, Enter opens it, Escape shuts a menu before the picker.
159
221
  field.onkeydown = (e) => {
160
- if (e.key !== "Enter" || !first) return;
161
- e.preventDefault();
162
- first.click();
222
+ if (e.isComposing) return;
223
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
224
+ e.preventDefault();
225
+ closeRowMenu();
226
+ light(active + (e.key === "ArrowDown" ? 1 : -1), true);
227
+ } else if (e.key === "Enter" && rows[active]) {
228
+ e.preventDefault();
229
+ rows[active].click();
230
+ }
231
+ };
232
+ dialog.onkeydown = (e) => {
233
+ if (e.key === "Escape" && !rowMenu.hidden) {
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ closeRowMenu();
237
+ field.focus();
238
+ }
163
239
  };
164
240
  paint();
165
241
  dialog.showModal();
166
242
  field.focus();
243
+ // Again now that it has a size: before showModal there is nothing to scroll.
244
+ light(active, true);
167
245
  }
168
246
 
169
-
170
247
  /**
171
248
  * A directory browser, for hosts with no folder dialog.
172
249
  *
@@ -294,6 +371,12 @@ const IS_WIN = /win/i.test(navigator.platform || navigator.userAgent || "");
294
371
  /** The rows the current query left on screen, in the order they are drawn. */
295
372
  let paletteRows = [];
296
373
  let paletteCursor = 0;
374
+ /**
375
+ * Whether the palette was opened from the sidebar's "Search sessions": then it
376
+ * lists sessions and nothing else, and every one of them rather than the
377
+ * recent eight — the question asked was "where is that conversation".
378
+ */
379
+ let paletteSessionsOnly = false;
297
380
 
298
381
  /** A nav item's own icon, so the palette names things the way the sidebar does. */
299
382
  function navIcon(id) {
@@ -315,6 +398,7 @@ const enLabel = (key) => (STRINGS.en && STRINGS.en[key]) || "";
315
398
  function paletteItems() {
316
399
  const items = [];
317
400
  const query = paletteField.value.trim();
401
+ if (paletteSessionsOnly) return sessionItems(sessionRows);
318
402
  const actions = t("palette.actions");
319
403
  const here = (projects.find((project) => project.current) || {}).path || currentCwd;
320
404
 
@@ -342,6 +426,7 @@ function paletteItems() {
342
426
  for (const [id, key] of [
343
427
  ["nav-artifacts", "nav.artifacts"],
344
428
  ["nav-sessions", "nav.sessions"],
429
+ ["nav-usage", "nav.usage"],
345
430
  ["nav-models", "nav.models"],
346
431
  ["nav-routines", "nav.routines"],
347
432
  ["nav-settings", "nav.settings"],
@@ -359,21 +444,7 @@ function paletteItems() {
359
444
  // Unfiltered, the list is a preview of the most recent work and the actions
360
445
  // above it still have to be visible. Once there is a query, every session in
361
446
  // every tree is fair game — that is what the field is for.
362
- const sessions = t("palette.sessions");
363
- for (const session of query ? sessionRows : sessionRows.slice(0, 8)) {
364
- items.push({
365
- kind: "session",
366
- group: sessions,
367
- icon: () => svgIcon("chat"),
368
- label: session.title || t("session.untitled"),
369
- sub: [projectName(session.cwd || ""), ago(session.updatedAt)].filter(Boolean).join(" · "),
370
- keywords: session.cwd || "",
371
- run: () => {
372
- if (session.current) return void setNav(null);
373
- void openSession(session.id, session.cwd);
374
- },
375
- });
376
- }
447
+ items.push(...sessionItems(query ? sessionRows : sessionRows.slice(0, 8)));
377
448
 
378
449
  const named = t("palette.projects");
379
450
  for (const project of query ? projects : projects.slice(0, 5)) {
@@ -394,6 +465,23 @@ function paletteItems() {
394
465
  return items;
395
466
  }
396
467
 
468
+ /** Palette rows for sessions, each reopening its own transcript. */
469
+ function sessionItems(rows) {
470
+ const group = t("palette.sessions");
471
+ return rows.map((session) => ({
472
+ kind: "session",
473
+ group,
474
+ icon: () => svgIcon("chat"),
475
+ label: session.title || t("session.untitled"),
476
+ sub: [projectName(session.cwd || ""), ago(session.updatedAt)].filter(Boolean).join(" · "),
477
+ keywords: session.cwd || "",
478
+ run: () => {
479
+ if (session.current) return void setNav(null);
480
+ void openSession(session.id, session.cwd);
481
+ },
482
+ }));
483
+ }
484
+
397
485
  /** Same word-by-word match the pickers use, over the row's hidden aliases too. */
398
486
  const paletteMatch = (item, query) =>
399
487
  matchesQuery(
@@ -454,12 +542,14 @@ function movePalette(step) {
454
542
  if (active) active.scrollIntoView({ block: "nearest" });
455
543
  }
456
544
 
457
- function openPalette() {
545
+ function openPalette(sessionsOnly = false) {
458
546
  // One modal at a time. A permission request is a question the agent is
459
547
  // blocked on; a palette over it would answer neither.
460
548
  if (document.querySelector("dialog[open]")) return;
461
549
  closeAttachMenu();
462
550
  closeRowMenu();
551
+ paletteSessionsOnly = sessionsOnly === true;
552
+ paletteField.placeholder = t(paletteSessionsOnly ? "palette.sessionsPlaceholder" : "palette.placeholder");
463
553
  paletteField.value = "";
464
554
  paletteCursor = 0;
465
555
  paintPalette();
@@ -471,7 +561,7 @@ function openPalette() {
471
561
  // The desktop shell reaches the palette through this: a menu accelerator is
472
562
  // handled before the page ever sees the keystroke, so the menu item calls the
473
563
  // same function the key would have.
474
- window.openCommandPalette = openPalette;
564
+ window.openCommandPalette = () => openPalette();
475
565
 
476
566
  paletteField.addEventListener("input", () => {
477
567
  paletteCursor = 0;
@@ -520,7 +610,8 @@ document.addEventListener("keydown", (e) => {
520
610
  openPalette();
521
611
  });
522
612
 
523
- document.getElementById("nav-search").addEventListener("click", openPalette);
613
+ document.getElementById("nav-search").addEventListener("click", () => openPalette());
614
+ document.getElementById("side-search").addEventListener("click", () => openPalette(true));
524
615
  // The chord as this machine actually spells it, in the one place a mouse user
525
616
  // will see it.
526
617
  document.getElementById("palette-hint").textContent = IS_MAC ? "⌘K" : "Ctrl K";
@@ -586,41 +677,107 @@ rail.addEventListener("focusout", hideRailTip);
586
677
  rail.addEventListener("click", hideRailTip);
587
678
 
588
679
  /**
589
- * What the empty state offers to do, grouped by the kind of work it is.
680
+ * What the empty state offers to do.
590
681
  *
591
- * One chip is drawn from each group, so the three on screen always span
592
- * writing code, getting through the day's work, and taking the thing to
593
- * market — three variations on "read my repo" would teach a new person that
594
- * this is a code-reading tool and nothing else. Each id names a label and a
595
- * "…prompt" string; the label is what fits on
596
- * a chip, the prompt is the sentence the chip actually sends.
682
+ * The server decides which kind: a project with no memory gets a rotating pool
683
+ * of everyday offers, and one the agent has notes on gets three written from
684
+ * those notes (see ../../starters.ts). Each chip's label is what fits on it,
685
+ * and the sentence it actually sends rides along in data-p.
597
686
  */
598
- const STARTERS = [
599
- ["explain", "bugs", "tests", "review", "faster"],
600
- ["plan", "docs", "digest", "automate", "backlog"],
601
- ["landing", "launch", "pitch", "position", "changelog"],
602
- ];
687
+ const FIXED_STARTERS = ["trip", "email", "research", "savings", "japanTrip"];
688
+ const VISIBLE_STARTERS = 3;
689
+ const STARTER_ROTATION_MS = 6000;
690
+ /** Which project the chips on screen were drawn for, and the newest ask. */
691
+ let startersFor = "";
692
+ let startersAsk = 0;
693
+ let starterOffset = 0;
694
+ let starterTimer;
695
+
696
+ function starterChip(label, prompt) {
697
+ const button = el("button", "", label);
698
+ button.type = "button";
699
+ button.dataset.p = prompt;
700
+ // The label is a chip; the title is the sentence it will actually send.
701
+ button.title = prompt;
702
+ return button;
703
+ }
704
+
705
+ function animateStarters(box) {
706
+ box.classList.remove("fresh");
707
+ void box.offsetWidth;
708
+ box.classList.add("fresh");
709
+ }
710
+
711
+ function stopStarterRotation() {
712
+ clearInterval(starterTimer);
713
+ starterTimer = undefined;
714
+ }
715
+
716
+ function drawFixedStarters(box, animate = false) {
717
+ const ids = Array.from({ length: VISIBLE_STARTERS }, (_, index) =>
718
+ FIXED_STARTERS[(starterOffset + index) % FIXED_STARTERS.length]);
719
+ box.replaceChildren(...ids.map((id) => starterChip(t("starter." + id), t("starter." + id + ".prompt"))));
720
+ if (animate) animateStarters(box);
721
+ }
722
+
723
+ function startStarterRotation(box) {
724
+ stopStarterRotation();
725
+ if (FIXED_STARTERS.length <= VISIBLE_STARTERS) return;
726
+ starterTimer = setInterval(() => {
727
+ // Do not move a choice out from under a mouse pointer or keyboard focus.
728
+ if (!box.offsetParent || box.matches(":hover") || box.matches(":focus-within")) return;
729
+ starterOffset = (starterOffset + 1) % FIXED_STARTERS.length;
730
+ drawFixedStarters(box, true);
731
+ }, STARTER_ROTATION_MS);
732
+ }
603
733
 
604
734
  /**
605
- * Draw one chip per group. The sentence rides along in data-p, and the click
606
- * is wired at the end of boot with the rest of them: a chip that submits before
607
- * the session it would submit to has loaded is worse than one that waits.
735
+ * Ask for the project's chips and draw them. Skipped when the project on
736
+ * screen has not changed, so a repaint of the project button does not pay for
737
+ * another round trip. The answer never waits on a model: a project whose set
738
+ * is not written yet gets the fixed three now, and the written set arrives
739
+ * later as a "starters" event (see startersArrived).
608
740
  */
609
- function paintStarters() {
741
+ async function paintStarters(cwd) {
610
742
  const box = document.getElementById("starters");
611
- if (!box) return;
743
+ if (!box || !cwd || cwd === startersFor) return;
744
+ stopStarterRotation();
745
+ startersFor = cwd;
746
+ starterOffset = 0;
747
+ const ask = ++startersAsk;
612
748
  box.replaceChildren();
613
- for (const group of STARTERS) {
614
- const id = group[Math.floor(Math.random() * group.length)];
615
- const button = el("button", "", t("starter." + id));
616
- button.type = "button";
617
- button.dataset.p = t("starter." + id + ".prompt");
618
- // The label is a chip; the title is the sentence it will actually send.
619
- button.title = button.dataset.p;
620
- box.appendChild(button);
749
+ let body;
750
+ try {
751
+ body = await getJson("/starters?cwd=" + encodeURIComponent(cwd) + "&locale=" + encodeURIComponent(LOCALE));
752
+ } catch {
753
+ body = { kind: "fixed" };
754
+ }
755
+ // A switch made while this was being written owns the box now.
756
+ if (ask !== startersAsk) return;
757
+ if (body && body.kind === "written" && Array.isArray(body.starters)) {
758
+ box.replaceChildren(...body.starters.map((item) => starterChip(item.label, item.prompt)));
759
+ } else {
760
+ drawFixedStarters(box);
761
+ startStarterRotation(box);
621
762
  }
622
763
  }
623
764
 
765
+ /**
766
+ * A set written in the background for a project, pushed when it lands.
767
+ *
768
+ * Swapped in only where it still fits: the same project and language on
769
+ * screen, and the empty state still showing — nobody's chip is replaced while
770
+ * they are reading a conversation, and the next visit reads it from disk.
771
+ */
772
+ function startersArrived(event) {
773
+ const box = document.getElementById("starters");
774
+ if (!box || !event || event.cwd !== startersFor || event.locale !== LOCALE || !Array.isArray(event.starters)) return;
775
+ if (!box.offsetParent) return;
776
+ stopStarterRotation();
777
+ box.replaceChildren(...event.starters.map((item) => starterChip(item.label, item.prompt)));
778
+ animateStarters(box);
779
+ }
780
+
624
781
  /**
625
782
  * The desktop shell's update hint. The state lives in the main process, which
626
783
  * decides whether a release gets a dialog or only this row; the page draws it
@@ -668,11 +825,128 @@ if (IS_DESKTOP && window.oharness.updates) {
668
825
  // Asked once as well: a release found before this page loaded (or before a
669
826
  // reopened window) was announced to nobody.
670
827
  window.oharness.updates.state().then(paintUpdateHint, () => {});
828
+ if (window.oharness.updates.onPrompt) window.oharness.updates.onPrompt(showUpdatePrompt);
829
+ }
830
+
831
+ /**
832
+ * The update questions, asked in the app's own dialog rather than the OS's.
833
+ * The main process decides when to ask and what a button does; the page only
834
+ * words the question and hands back which button it was — 0 is the one that
835
+ * acts, and Escape answers -1, the same as Later.
836
+ */
837
+ function showUpdatePrompt(prompt) {
838
+ const kind = prompt && prompt.kind;
839
+ const vars = { version: prompt.version || "", current: prompt.current || "" };
840
+ const dialog = el("dialog", "upd-dialog");
841
+ if (prompt.important) dialog.classList.add("important");
842
+ const head = el("div", "upd-head");
843
+ head.appendChild(el("span", "upd-mark", kind === "error" ? "!" : "↑"));
844
+ const title =
845
+ kind === "download"
846
+ ? t(prompt.important ? "update.prompt.importantTitle" : "update.prompt.availableTitle", vars)
847
+ : kind === "restart"
848
+ ? t("update.prompt.readyTitle", vars)
849
+ : kind === "current"
850
+ ? t("update.prompt.currentTitle")
851
+ : kind === "error"
852
+ ? t("update.prompt.errorTitle")
853
+ : t("update.prompt.devTitle");
854
+ head.appendChild(el("div", "upd-title", title));
855
+ dialog.appendChild(head);
856
+
857
+ if (kind === "download" && prompt.notes) dialog.appendChild(el("div", "upd-notes", prompt.notes));
858
+ const body =
859
+ kind === "download"
860
+ ? t("update.prompt.availableBody", vars)
861
+ : kind === "restart"
862
+ ? t("update.prompt.readyBody")
863
+ : kind === "current"
864
+ ? t("update.prompt.currentBody", vars)
865
+ : kind === "error"
866
+ ? prompt.detail || ""
867
+ : t("update.prompt.devBody");
868
+ if (body) dialog.appendChild(el("div", kind === "error" ? "upd-body mono" : "upd-body", body));
869
+
870
+ const actions = el("div", "dlg-actions");
871
+ const finish = (response) => {
872
+ window.oharness.updates.answer(prompt.id, response);
873
+ if (dialog.open) dialog.close();
874
+ dialog.remove();
875
+ };
876
+ let main;
877
+ let focus;
878
+ if (kind === "download" || kind === "restart") {
879
+ const later = el("button", "quiet", t("update.prompt.later"));
880
+ main = el("button", "approve", t(kind === "download" ? "update.prompt.download" : "update.prompt.restart"));
881
+ later.type = main.type = "button";
882
+ later.onclick = () => finish(1);
883
+ main.onclick = () => finish(0);
884
+ actions.append(later, main);
885
+ // Restarting ends whatever is running, so Enter must not do it by accident.
886
+ focus = kind === "restart" ? later : main;
887
+ } else {
888
+ main = el("button", "approve", t("update.prompt.ok"));
889
+ main.type = "button";
890
+ main.onclick = () => finish(0);
891
+ actions.append(main);
892
+ focus = main;
893
+ }
894
+ dialog.appendChild(actions);
895
+ dialog.addEventListener("cancel", (e) => {
896
+ e.preventDefault();
897
+ finish(-1);
898
+ });
899
+ document.body.appendChild(dialog);
900
+ dialog.showModal();
901
+ focus.focus();
902
+ }
903
+
904
+ /**
905
+ * A prompt from a link on the website — oharness://new?prompt=… — opened as a
906
+ * new session with the text in the composer, and nothing more.
907
+ *
908
+ * The main process checked and cleaned the text (desktop/deep-link.js), but
909
+ * it came from whatever page the person was on, so this page treats it as
910
+ * someone else's suggestion: it is never sent, no permission is changed and
911
+ * no card is answered. It sits in the box until the person reads it and
912
+ * presses send, or deletes it. A new session rather than the open one, so the
913
+ * suggestion never lands in the middle of a conversation as if it belonged
914
+ * there; and a draft already in the box is only replaced when the person says
915
+ * so, because the box is shared by every tab and the draft may be the only
916
+ * copy of what they were writing.
917
+ *
918
+ * Not before boot has finished: switching sessions while the first one is
919
+ * still being opened leaves the two fighting over the page. Boot takes the
920
+ * one that launched the app at the end; the nudge covers every later click.
921
+ */
922
+ let deepLinkReady = false;
923
+
924
+ async function takeDeepLink() {
925
+ if (!deepLinkReady || !IS_DESKTOP || !window.oharness.deepLink) return;
926
+ let text;
927
+ try {
928
+ text = await window.oharness.deepLink.take();
929
+ } catch {
930
+ return;
931
+ }
932
+ if (typeof text !== "string" || !text) return;
933
+ const draft = input.value.trim();
934
+ if (draft && draft !== text && !(await askConfirm({ body: t("deepLink.replaceDraft"), ok: t("dialog.replace") }))) return;
935
+ await switchTo({ action: "new" });
936
+ input.value = text;
937
+ // Called rather than an input event, which would also open the command
938
+ // menu for a prompt that starts with "/" — one Enter from running it.
939
+ autosize();
940
+ paintMirror();
941
+ input.focus();
942
+ input.setSelectionRange(input.value.length, input.value.length);
943
+ }
944
+ if (IS_DESKTOP && window.oharness.deepLink) {
945
+ window.oharness.deepLink.onArrived(() => void takeDeepLink());
671
946
  }
672
947
 
673
948
  void (async () => {
674
949
  applyStrings();
675
- paintStarters();
676
950
  // The language is here because it is the one thing that decides what to
677
951
  // translate next, and the shell because a desktop window and a browser tab
678
952
  // are different products to the person using them.
@@ -727,18 +1001,26 @@ void (async () => {
727
1001
  await loadSidePanels();
728
1002
  await loadProjects();
729
1003
 
730
- for (const button of document.querySelectorAll("#starters button")) {
731
- button.addEventListener("click", () => {
732
- input.value = button.dataset.p;
733
- paintMirror();
734
- document.getElementById("composer").requestSubmit();
735
- });
736
- }
1004
+ // Wired here, after the session has loaded: a chip that submits before the
1005
+ // session it would submit to exists is worse than one that waits. Delegated,
1006
+ // because the chips are redrawn on every project switch. Absent when the tab
1007
+ // opened on a conversation: its first entry took the invitation off the
1008
+ // page, and a throw here stopped everything after it, the session list too.
1009
+ document.getElementById("starters")?.addEventListener("click", (event) => {
1010
+ const button = event.target.closest("#starters button");
1011
+ if (!button) return;
1012
+ stopStarterRotation();
1013
+ input.value = button.dataset.p;
1014
+ paintMirror();
1015
+ document.getElementById("composer").requestSubmit();
1016
+ });
737
1017
  await loadSessions();
738
1018
  await refreshRoutineCount();
739
1019
  await refreshPluginPages();
740
1020
  await refreshModelCount();
741
1021
  await loadAccount();
742
1022
  input.focus();
1023
+ deepLinkReady = true;
1024
+ await takeDeepLink();
743
1025
  })();`;
744
1026
  //# sourceMappingURL=shell.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAiuBzB,CAAC"}
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MA2/BzB,CAAC"}