@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
@@ -22,55 +22,180 @@ async function newRoutine() {
22
22
  input.setSelectionRange(input.value.length, input.value.length);
23
23
  }
24
24
 
25
- /** One run as a line: when, how it went, and what it cost. */
26
- function runSummary(run) {
27
- const when = new Date(run.startedAt).toLocaleString();
28
- if (run.skipped) return t("routines.skipped", { when });
29
- const parts = [t(run.ok ? "routines.lastOk" : "routines.lastFailed", { when })];
30
- if (run.catchUp) parts.push(t("routines.catchUp"));
31
- parts.push(t("routines.turns", { n: run.turns }));
32
- if (run.costUsd) parts.push("$" + run.costUsd.toFixed(4));
33
- return parts.join(" · ");
25
+ /** A span of time as someone reads it: 42s, 3m 05s, 1h 12m. */
26
+ function runDuration(ms) {
27
+ const sec = Math.max(0, Math.round(ms / 1000));
28
+ if (sec < 60) return sec + "s";
29
+ const min = Math.floor(sec / 60);
30
+ if (min < 60) return min + "m " + String(sec % 60).padStart(2, "0") + "s";
31
+ return Math.floor(min / 60) + "h " + (min % 60) + "m";
32
+ }
33
+
34
+ /** The first line worth reading out of what a run said, or why it stopped. */
35
+ function runExcerpt(run) {
36
+ const text = run.error || run.output || "";
37
+ const line = text.split("\n").map((one) => one.replace(/^[#>*\-\s]+/, "").trim()).find(Boolean) || "";
38
+ return line.length > 160 ? line.slice(0, 159) + "…" : line;
34
39
  }
35
40
 
36
41
  /**
37
- * A routine's past runs, fetched when opened.
42
+ * What the page remembers across repaints: which histories are folded away
43
+ * and which runs are open. The panel is rebuilt whenever a run starts or
44
+ * ends, and losing what someone had opened each time would make it unusable
45
+ * while a routine is going.
46
+ */
47
+ const foldedHistories = new Set();
48
+ const openRuns = new Set();
49
+ const runKey = (routine, run) => routine.name + "\0" + run.startedAt;
50
+
51
+ /**
52
+ * One run in the history: a status, when, how long, what it cost, and one
53
+ * line under it — what it is doing, while it runs; the start of its answer or
54
+ * its error, after. Clicking opens it in place to the whole of that text,
55
+ * with a way into its conversation.
56
+ */
57
+ function routineRunRow(run, routine) {
58
+ const state = run.running ? "running" : run.skipped ? "skipped" : run.ok ? "ok" : "failed";
59
+ const key = runKey(routine, run);
60
+ const row = el("div", "routine-run " + state);
61
+ row.dataset.routine = routine.name;
62
+ const top = el("div", "routine-run-top");
63
+ top.appendChild(el("span", "run-status " + state, t("routines.status." + state)));
64
+ top.appendChild(el("span", null, new Date(run.startedAt).toLocaleString()));
65
+ const facts = el("span", "muted routine-run-facts");
66
+ if (run.running) {
67
+ const tick = () => {
68
+ if (!facts.isConnected && facts.dataset.painted) return clearInterval(timer);
69
+ facts.dataset.painted = "1";
70
+ const bits = [t("routines.elapsed", { d: runDuration(Date.now() - run.startedAt) })];
71
+ const turns = Number(row.dataset.turns || run.turns || 0);
72
+ if (turns) bits.push(t("routines.turns", { n: turns }));
73
+ facts.textContent = bits.join(" · ");
74
+ };
75
+ const timer = setInterval(tick, 1000);
76
+ tick();
77
+ } else if (!run.skipped) {
78
+ const bits = [runDuration(run.finishedAt - run.startedAt), t("routines.turns", { n: run.turns })];
79
+ if (run.costUsd) bits.push("$" + run.costUsd.toFixed(4));
80
+ if (run.catchUp) bits.push(t("routines.catchUp"));
81
+ facts.textContent = bits.join(" · ");
82
+ } else if (run.reason === "still-running") {
83
+ facts.textContent = t("routines.stillRunning");
84
+ }
85
+ top.appendChild(facts);
86
+ row.appendChild(top);
87
+
88
+ const full = run.running ? run.activity || "" : run.error || run.output || "";
89
+ const line = el("div", "routine-run-text" + (run.error ? " err" : " muted"),
90
+ run.running ? full || t("routines.starting") : runExcerpt(run));
91
+ row.appendChild(line);
92
+ if (!line.textContent) line.remove();
93
+
94
+ const detail = el("div", "routine-run-detail");
95
+ if (full && !run.running) {
96
+ // The answer as the conversation shows it; an error stays plain text.
97
+ const body = el("div", "routine-run-full" + (run.error ? " err" : " md"));
98
+ if (run.error) body.textContent = full;
99
+ else body.appendChild(renderMarkdown(full));
100
+ detail.appendChild(body);
101
+ }
102
+ if (run.sessionId) {
103
+ const open = el("button", "ghost small", t("routines.openRun"));
104
+ open.type = "button";
105
+ open.addEventListener("click", (event) => {
106
+ event.stopPropagation();
107
+ void openSession(run.sessionId, routine.cwd);
108
+ });
109
+ detail.appendChild(open);
110
+ }
111
+ if (!detail.childNodes.length) return row;
112
+
113
+ row.appendChild(detail);
114
+ row.tabIndex = 0;
115
+ row.setAttribute("role", "button");
116
+ const setOpen = (on) => {
117
+ row.classList.toggle("open", on);
118
+ row.setAttribute("aria-expanded", on ? "true" : "false");
119
+ if (on) openRuns.add(key); else openRuns.delete(key);
120
+ };
121
+ setOpen(openRuns.has(key));
122
+ row.addEventListener("click", (event) => {
123
+ // Selecting text in the full answer is reading it, not closing it.
124
+ if (String(getSelection() || "")) return;
125
+ if (event.target.closest(".routine-run-full") && row.classList.contains("open")) return;
126
+ setOpen(!row.classList.contains("open"));
127
+ });
128
+ row.addEventListener("keydown", (event) => {
129
+ if (event.target !== row || (event.key !== "Enter" && event.key !== " ")) return;
130
+ event.preventDefault();
131
+ setOpen(!row.classList.contains("open"));
132
+ });
133
+ return row;
134
+ }
135
+
136
+ /** How many runs a card shows before "Show all". */
137
+ const RUNS_SHOWN = 5;
138
+
139
+ /**
140
+ * A routine's runs, newest first, the one going now on top.
38
141
  *
39
- * Skipped times are listed with the runs, with why: a routine that "didn't
40
- * run" is the question this answers, and the answer is usually that the
41
- * previous run was still going. A run opens its session — the whole of what
42
- * the agent did, not the last paragraph of it.
142
+ * Open unless folded: whether it ran, and what came of it, is what anyone
143
+ * opening this page wants to know. Repainted whenever a run starts or ends,
144
+ * and patched in place as a running one moves on, so a run started here is
145
+ * on the page at once and turns into its result when it finishes.
43
146
  */
44
147
  function routineHistory(routine) {
45
- const box = el("details", "routine-history");
46
- box.appendChild(el("summary", "muted small", t("routines.history")));
148
+ const box = el("div", "routine-history");
149
+ const head = el("button", "routine-history-head muted small");
150
+ head.type = "button";
151
+ const label = el("span", null, t("routines.history"));
152
+ head.append(el("span", "chev", "▾"), label);
153
+ box.appendChild(head);
47
154
  const list = el("div", "routine-runs");
48
155
  box.appendChild(list);
49
- box.addEventListener("toggle", () => {
50
- if (!box.open || list.dataset.loaded) return;
51
- list.dataset.loaded = "1";
52
- list.appendChild(el("div", "muted small", t("routines.loading")));
53
- void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
156
+ const fold = (on) => {
157
+ box.classList.toggle("folded", on);
158
+ head.setAttribute("aria-expanded", on ? "false" : "true");
159
+ if (on) foldedHistories.add(routine.name); else foldedHistories.delete(routine.name);
160
+ };
161
+ fold(foldedHistories.has(routine.name));
162
+ head.addEventListener("click", () => fold(!box.classList.contains("folded")));
163
+
164
+ list.appendChild(el("div", "muted small", t("routines.loading")));
165
+ void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
166
+ list.replaceChildren();
167
+ const runs = (body && body.runs) || [];
168
+ if (runs.length) label.textContent = t("routines.historyCount", { n: runs.filter((run) => !run.running).length });
169
+ if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
170
+ const paint = (all) => {
54
171
  list.replaceChildren();
55
- const runs = (body && body.runs) || [];
56
- if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
57
- for (const run of runs) {
58
- const line = el(run.sessionId ? "button" : "div", "routine-run small" + (run.ok || run.skipped ? "" : " failed"));
59
- line.appendChild(el("span", null, runSummary(run)));
60
- if (run.skipped && run.reason === "still-running") {
61
- line.appendChild(el("span", "muted", t("routines.stillRunning")));
62
- }
63
- if (run.sessionId) {
64
- line.type = "button";
65
- line.addEventListener("click", () => void openSession(run.sessionId, routine.cwd));
66
- }
67
- list.appendChild(line);
172
+ for (const run of all ? runs : runs.slice(0, RUNS_SHOWN)) list.appendChild(routineRunRow(run, routine));
173
+ if (runs.length > RUNS_SHOWN) {
174
+ const more = el("button", "ghost small routine-more", all ? t("routines.showFewer") : t("routines.showAll", { n: runs.length }));
175
+ more.type = "button";
176
+ more.addEventListener("click", () => paint(!all));
177
+ list.appendChild(more);
68
178
  }
69
- });
179
+ };
180
+ paint(false);
70
181
  });
71
182
  return box;
72
183
  }
73
184
 
185
+ /** A running routine moved on: say so in its row, without a repaint. */
186
+ function paintRoutineActivity(event) {
187
+ for (const row of document.querySelectorAll(".routine-run.running")) {
188
+ if (row.dataset.routine !== event.name) continue;
189
+ row.dataset.turns = String(event.turns || 0);
190
+ let line = row.querySelector(".routine-run-text");
191
+ if (!line) {
192
+ line = el("div", "routine-run-text muted");
193
+ row.querySelector(".routine-run-top").after(line);
194
+ }
195
+ if (event.activity) line.textContent = event.activity;
196
+ }
197
+ }
198
+
74
199
  /**
75
200
  * Scheduled runs, read from the scheduler rather than kept here.
76
201
  *
@@ -103,6 +228,10 @@ async function showRoutines() {
103
228
  const head = el("div", "row-head");
104
229
  head.appendChild(el("span", "row-title", routine.name));
105
230
  head.appendChild(el("span", "mono row-cron", routine.cron));
231
+ // What it runs on, and what it needs connected: the two things a run
232
+ // fails on without anyone there to fix them.
233
+ if (routine.model) head.appendChild(el("span", "muted small routine-where", routine.model.split("/").pop()));
234
+ if (routine.apps && routine.apps.length) head.appendChild(el("span", "muted small routine-where", routine.apps.join(", ")));
106
235
  if (!routine.enabled) head.appendChild(el("span", "tag", t("routines.paused")));
107
236
  // Where it runs. A routine is made in one project and runs there, from
108
237
  // whichever project is open when its time comes, so the card says which.
@@ -112,8 +241,9 @@ async function showRoutines() {
112
241
  head.appendChild(where);
113
242
  }
114
243
 
115
- const run = el("button", "ghost", t("routines.run"));
244
+ const run = el("button", "ghost", t(routine.running ? "routines.running" : "routines.run"));
116
245
  run.type = "button";
246
+ run.disabled = Boolean(routine.running);
117
247
  run.addEventListener("click", () => {
118
248
  run.disabled = true;
119
249
  run.textContent = t("routines.running");
@@ -145,8 +275,8 @@ async function showRoutines() {
145
275
 
146
276
  const remove = el("button", "ghost", t("routines.remove"));
147
277
  remove.type = "button";
148
- remove.addEventListener("click", () => {
149
- if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
278
+ remove.addEventListener("click", async () => {
279
+ if (!(await askConfirm({ body: t("routines.removeConfirm", { name: routine.name }), ok: t("routines.remove"), danger: true }))) return;
150
280
  remove.disabled = true;
151
281
  void api("/routine/remove", { name: routine.name }).then((result) => {
152
282
  pushNotice(result);
@@ -161,11 +291,9 @@ async function showRoutines() {
161
291
  if (routine.enabled && routine.nextRunAt) {
162
292
  bits.push(t("routines.next", { when: new Date(routine.nextRunAt).toLocaleString() }));
163
293
  }
164
- if (routine.lastRun) bits.push(runSummary(routine.lastRun));
294
+ // The last run is the first line of the history below, so it is not
295
+ // repeated here.
165
296
  if (bits.length) row.appendChild(el("div", "muted small", bits.join(" · ")));
166
- if (routine.lastRun && routine.lastRun.error) {
167
- row.appendChild(el("div", "err small", routine.lastRun.error));
168
- }
169
297
  row.appendChild(routineHistory(routine));
170
298
  panel.appendChild(row);
171
299
  }
@@ -187,7 +315,10 @@ async function showRoutines() {
187
315
  * because a switch you have to open a row to reach is a switch that has been
188
316
  * hidden rather than tidied.
189
317
  */
190
- async function showSettings() {
318
+ async function showSettings(opts) {
319
+ // A row whose controls live inside it is reopened after a change redraws
320
+ // the page; called from a click handler, opts is the event, and opens none.
321
+ const opened = opts && typeof opts.open === "string" ? opts.open : undefined;
191
322
  const settings = await getJson("/settings");
192
323
  panelBody.replaceChildren();
193
324
  if (settings.error) return void panelBody.appendChild(panelError(settings.error));
@@ -197,7 +328,7 @@ async function showSettings() {
197
328
 
198
329
  // Rows are grouped by what they are about. Each group is filled as the page
199
330
  // is built and drawn at the end under its heading, and only when it has rows.
200
- const groups = { workspace: el("div"), sync: el("div"), memory: el("div"), activity: el("div"), advanced: el("div"), about: el("div") };
331
+ const groups = { workspace: el("div"), remote: el("div"), sync: el("div"), memory: el("div"), activity: el("div"), advanced: el("div"), about: el("div") };
201
332
  let target = groups.workspace;
202
333
 
203
334
  // A click on a button inside a summary toggles the disclosure as well as
@@ -311,6 +442,7 @@ async function showSettings() {
311
442
  change.addEventListener("click", openProjectPicker);
312
443
  field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
313
444
  permissionsField(settings);
445
+ claudeAgentField(settings.claudeAgent || { on: false, available: false });
314
446
 
315
447
  // A door, not a report: the numbers live on a page of their own, and the row
316
448
  // here carries no total of its own because working one out means walking
@@ -460,6 +592,34 @@ async function showSettings() {
460
592
  * and a "Show trail" you had to find after that was one click too many for
461
593
  * the only thing anybody opens this row to see.
462
594
  */
595
+ /**
596
+ * Claude Agent: the user's own Claude Code, on their own Claude account, as
597
+ * a subagent and as @claude.
598
+ *
599
+ * Off until switched on here, because it spends someone's Claude plan and
600
+ * that is theirs to decide — the hint says so in so many words before the
601
+ * switch is flipped, not after. With no Claude Code on this machine the
602
+ * switch is shown but shut, and the row says where to get it.
603
+ */
604
+ function claudeAgentField(status) {
605
+ const value = !status.available
606
+ ? t("settings.claudeAgentMissingValue")
607
+ : t(status.on ? "settings.claudeAgentOnValue" : "settings.claudeAgentOffValue");
608
+ const body = field(
609
+ t("settings.claudeAgent"),
610
+ value,
611
+ t("settings.claudeAgentHint"),
612
+ toggle(status.on, t("settings.claudeAgent"), (next) => {
613
+ void api("/settings/claude-agent", { enabled: next }).then((result) => {
614
+ if (result && result.error) addNotice(result.error, "err");
615
+ void showSettings();
616
+ });
617
+ }, !status.available && !status.on),
618
+ );
619
+ if (!status.available) body.appendChild(el("div", "muted small", t("settings.claudeAgentMissing")));
620
+ else if (status.unavailable) body.appendChild(el("div", "muted small", status.unavailable));
621
+ }
622
+
463
623
  function auditField(audit) {
464
624
  const mine = audit.source !== "project";
465
625
 
@@ -664,7 +824,9 @@ async function showSettings() {
664
824
  // a switch a browser tab cannot honour would be one that lies.
665
825
  const keepAwake = await getJson("/keep-awake");
666
826
  if (keepAwake && !keepAwake.error && keepAwake.available) {
667
- target = groups.workspace;
827
+ // Set once, if ever: under Advanced, folded away with the other rows
828
+ // nobody comes to Settings to change.
829
+ target = groups.advanced;
668
830
  field(
669
831
  t("settings.keepAwake"),
670
832
  t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
@@ -674,6 +836,97 @@ async function showSettings() {
674
836
  }),
675
837
  );
676
838
  }
839
+ // Remote control: a phone on this account driving this machine through the
840
+ // relay. Offered only where the server was given a relay to use, and off
841
+ // until somebody at the machine turns it on.
842
+ const remote = await getJson("/remote");
843
+ if (remote && !remote.error && remote.available) {
844
+ target = groups.remote;
845
+ const stateKey = (view) =>
846
+ view.state === "online" ? "online" : view.state === "connecting" ? "connecting" : view.state === "signed-out" ? "signedOut" : "off";
847
+ const body = field(
848
+ t("settings.remote"),
849
+ t("settings.remote." + stateKey(remote)),
850
+ t("settings.remoteHint"),
851
+ toggle(remote.on, t("settings.remote"), (next) => {
852
+ void api("/remote", { enabled: next }).then((result) => {
853
+ if (result && result.error) addNotice(result.error, "err");
854
+ void showSettings();
855
+ });
856
+ }),
857
+ );
858
+ if (remote.keepsAwake) body.appendChild(el("div", "muted small", t("settings.remoteAwake")));
859
+
860
+ // Where it stands, said in a sentence: off, waiting on an account,
861
+ // reaching for the relay, or there.
862
+ const status = el("div", "setting-status small");
863
+ const dot = el("span", "status-dot");
864
+ const said = el("span");
865
+ status.append(dot, said);
866
+ body.appendChild(status);
867
+ let signIn = null;
868
+ if (typeof openConnect === "function") {
869
+ signIn = el("button", "ghost", t("settings.remoteSignIn"));
870
+ signIn.type = "button";
871
+ signIn.addEventListener("click", () => openConnect("official"));
872
+ body.appendChild(signIn);
873
+ }
874
+ const row = body.parentNode;
875
+ const shownValue = row.querySelector(".setting-value");
876
+ const paint = (view) => {
877
+ const key = stateKey(view);
878
+ dot.className = "status-dot" + (key === "online" ? " ok" : "");
879
+ said.textContent = t("settings.remoteStatus." + key);
880
+ if (shownValue) shownValue.textContent = t("settings.remote." + key);
881
+ if (signIn) signIn.hidden = key !== "signedOut";
882
+ };
883
+ paint(remote);
884
+ // Connecting and signing in finish on their own time, so the row looks
885
+ // again while it is on screen and not yet online.
886
+ if (remote.on) {
887
+ const timer = setInterval(() => {
888
+ if (!document.body.contains(row)) return clearInterval(timer);
889
+ void getJson("/remote").then((view) => {
890
+ if (!view || view.error || !document.body.contains(row)) return;
891
+ paint(view);
892
+ if (!view.on || view.state === "online") clearInterval(timer);
893
+ });
894
+ }, 3000);
895
+ }
896
+
897
+ // The phones allowed in so far. Forgetting one ends its connection now
898
+ // and sends it back to the Allow card next time.
899
+ const phones = el("div", "setting-list");
900
+ phones.appendChild(el("div", "muted small", t("settings.remotePhones")));
901
+ const devices = remote.devices || [];
902
+ if (!devices.length) phones.appendChild(el("div", "small", t("settings.remoteNoPhones")));
903
+ for (const device of devices) {
904
+ const line = el("div", "remote-phone small");
905
+ line.appendChild(el("span", "remote-phone-name", device.name || device.id));
906
+ if (device.pairedAt) {
907
+ line.appendChild(el("span", "muted", t("settings.remotePairedOn", { date: new Date(device.pairedAt).toLocaleDateString() })));
908
+ }
909
+ const forget = el("button", "ghost", t("settings.remoteForget"));
910
+ forget.type = "button";
911
+ forget.addEventListener("click", () => {
912
+ forget.disabled = true;
913
+ void api("/remote/revoke", { id: device.id }).then((result) => {
914
+ if (result && result.error) {
915
+ forget.disabled = false;
916
+ return void addNotice(result.error, "err");
917
+ }
918
+ addNotice(t("settings.remoteForgotten", { name: device.name || device.id }));
919
+ void showSettings();
920
+ });
921
+ });
922
+ line.appendChild(forget);
923
+ phones.appendChild(line);
924
+ }
925
+ body.appendChild(phones);
926
+ }
927
+ // Computer use: only in the desktop app, which ships the helper. Read here,
928
+ // drawn after the browser row, its neighbour in kind.
929
+ const computer = await getJson("/computer-use/settings");
677
930
  // Which browsers the agent may use. Three choices, so a segmented control
678
931
  // rather than a switch; absent when the browser tools are off altogether,
679
932
  // since then there is nothing for it to move.
@@ -707,6 +960,105 @@ async function showSettings() {
707
960
  body.appendChild(status);
708
961
  body.appendChild(extensionHelp(ext));
709
962
  }
963
+ // Computer use, as one row: the row says how it gets switched on, and opens
964
+ // onto the three things a person owns about it — whether the agent may
965
+ // switch it on itself, whether it may ever take the real mouse and
966
+ // keyboard, and their own additions to the apps it refuses.
967
+ if (computer && !computer.error && computer.available) {
968
+ target = groups.workspace;
969
+ const enableModes = ["ask", "auto", "off"];
970
+ const enable = enableModes.includes(computer.enable) ? computer.enable : "ask";
971
+ const body = field(t("settings.computer"), t("settings.computerEnable." + enable), "");
972
+ const sub = (label, control, hint) => {
973
+ const part = el("div", "setting-part");
974
+ const line = el("div", "setting-part-line");
975
+ line.appendChild(el("div", "setting-part-name", label));
976
+ line.appendChild(control);
977
+ part.appendChild(line);
978
+ if (hint) part.appendChild(el("div", "muted small", hint));
979
+ body.appendChild(part);
980
+ };
981
+
982
+ const enableSeg = el("div", "seg setting-seg");
983
+ enableSeg.setAttribute("role", "radiogroup");
984
+ enableSeg.setAttribute("aria-label", t("settings.computerEnable"));
985
+ for (const mode of enableModes) {
986
+ const chosen = mode === enable;
987
+ const button = el("button", "seg-item" + (chosen ? " on" : ""), t("settings.computerEnable." + mode));
988
+ button.type = "button";
989
+ button.setAttribute("role", "radio");
990
+ button.setAttribute("aria-checked", chosen ? "true" : "false");
991
+ button.addEventListener("click", () => {
992
+ if (chosen) return;
993
+ enableSeg.querySelectorAll("button").forEach((b) => (b.disabled = true));
994
+ void api("/computer-use/settings", { enable: mode }).then(() => showSettings({ open: "computer" }));
995
+ });
996
+ enableSeg.appendChild(button);
997
+ }
998
+ sub(t("settings.computerEnable"), enableSeg, t("settings.computerEnableHint." + enable));
999
+
1000
+ const takeover = computer.takeover !== "never";
1001
+ sub(
1002
+ t("settings.computerTakeover"),
1003
+ toggle(takeover, t("settings.computerTakeover"), (next) => {
1004
+ void api("/computer-use/settings", { takeover: next ? "ask" : "never" }).then(() => showSettings({ open: "computer" }));
1005
+ }),
1006
+ t("settings.computerTakeoverHint"),
1007
+ );
1008
+
1009
+ const blocked = el("div", "setting-list setting-part");
1010
+ blocked.appendChild(el("div", "setting-part-name", t("settings.computerBlockedTitle")));
1011
+ blocked.appendChild(el("div", "muted small", t("settings.computerBlocked")));
1012
+ const box = el("textarea", "boxed");
1013
+ box.value = (computer.blockedApps || []).join("\n");
1014
+ box.rows = Math.max(2, (computer.blockedApps || []).length + 1);
1015
+ box.spellcheck = false;
1016
+ box.style.width = "100%";
1017
+ box.placeholder = t("settings.computerBlockedPlaceholder");
1018
+ blocked.appendChild(box);
1019
+ const save = el("button", "ghost", t("perms.save"));
1020
+ save.type = "button";
1021
+ save.addEventListener("click", () => {
1022
+ save.disabled = true;
1023
+ const apps = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
1024
+ void api("/computer-use/settings", { blockedApps: apps }).then((result) => {
1025
+ save.disabled = false;
1026
+ if (result && result.error) return void addNotice(result.error, "err");
1027
+ addNotice(t("perms.saved"));
1028
+ });
1029
+ });
1030
+ blocked.appendChild(save);
1031
+ body.appendChild(blocked);
1032
+ body.parentElement.dataset.row = "computer";
1033
+ if (opened === "computer") body.parentElement.open = true;
1034
+ }
1035
+ // One switch for both: the pictures of what a turn left in the browser and
1036
+ // on the computer, under its reply. Shown wherever either can run.
1037
+ const shots = await getJson("/turn-shots");
1038
+ if (shots && !shots.error && (browserModes.includes(settings.browserMode) || (computer && computer.available))) {
1039
+ target = groups.advanced;
1040
+ field(
1041
+ t("settings.turnShots"),
1042
+ t(shots.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
1043
+ t("settings.turnShotsHint"),
1044
+ toggle(shots.enabled, t("settings.turnShots"), (next) => {
1045
+ void api("/turn-shots", { enabled: next }).then(showSettings);
1046
+ }),
1047
+ );
1048
+ }
1049
+ // The grey next message in the empty composer, written after each turn.
1050
+ const suggestions = await getJson("/suggestions");
1051
+ if (suggestions && !suggestions.error) {
1052
+ target = groups.workspace;
1053
+ field(
1054
+ t("settings.suggestions"),
1055
+ t(suggestions.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
1056
+ t("settings.suggestionsHint"),
1057
+ toggle(suggestions.enabled, t("settings.suggestions"), (next) => {
1058
+ void api("/suggestions", { enabled: next }).then(showSettings);
1059
+ }),
1060
+ );
1061
+ }
710
1062
  // The desktop app's version, with its release notes beside it. A row that
711
1063
  // is always there, because the menu bar is not where people look and the
712
1064
  // post-update hint is gone after one click.
@@ -726,6 +1078,7 @@ async function showSettings() {
726
1078
  }
727
1079
  const sections = [
728
1080
  ["settings.section.workspace", [groups.workspace]],
1081
+ ["settings.section.remote", [groups.remote]],
729
1082
  ["settings.section.sync", [groups.sync, groups.memory]],
730
1083
  ["settings.section.activity", [groups.activity]],
731
1084
  ["settings.section.advanced", [groups.advanced]],
@@ -733,12 +1086,46 @@ async function showSettings() {
733
1086
  ];
734
1087
  for (const [title, parts] of sections) {
735
1088
  if (!parts.some((part) => part.childNodes.length)) continue;
1089
+ if (title === "settings.section.advanced") {
1090
+ panel.appendChild(advancedSection(parts));
1091
+ continue;
1092
+ }
736
1093
  panel.appendChild(el("div", "section-title", t(title)));
737
1094
  for (const part of parts) panel.appendChild(part);
738
1095
  }
739
1096
  panelBody.appendChild(panel);
740
1097
  }
741
1098
 
1099
+ /**
1100
+ * Advanced: the rows nobody opens Settings to change, folded under their
1101
+ * heading so the ones people do are the page. Shut until opened, and then
1102
+ * open on every visit in this browser until shut again — a person who went
1103
+ * looking once is likely to go looking again.
1104
+ */
1105
+ const ADVANCED_OPEN_KEY = "oh.settings.advancedOpen";
1106
+
1107
+ function advancedSection(parts) {
1108
+ const section = el("details", "settings-advanced");
1109
+ const head = el("summary", "section-title");
1110
+ head.appendChild(el("span", "chev"));
1111
+ head.appendChild(document.createTextNode(t("settings.section.advanced")));
1112
+ section.appendChild(head);
1113
+ for (const part of parts) section.appendChild(part);
1114
+ try {
1115
+ section.open = localStorage.getItem(ADVANCED_OPEN_KEY) === "1";
1116
+ } catch {
1117
+ // Storage refused: shut, as on a first visit.
1118
+ }
1119
+ section.addEventListener("toggle", () => {
1120
+ try {
1121
+ localStorage.setItem(ADVANCED_OPEN_KEY, section.open ? "1" : "0");
1122
+ } catch {
1123
+ // Not remembered, which costs one click next time.
1124
+ }
1125
+ });
1126
+ return section;
1127
+ }
1128
+
742
1129
  /**
743
1130
  * Where the money went, and on which model.
744
1131
  *
@@ -1 +1 @@
1
- {"version":3,"file":"routines.js","sourceRoot":"","sources":["../../../src/page/client/routines.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kFAy5BgD,CAAC"}
1
+ {"version":3,"file":"routines.js","sourceRoot":"","sources":["../../../src/page/client/routines.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kFA4xCgD,CAAC"}