@oharness/server 0.9.3 → 0.10.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 (59) hide show
  1. package/dist/i18n.js +354 -62
  2. package/dist/i18n.js.map +1 -1
  3. package/dist/main.js +4 -0
  4. package/dist/main.js.map +1 -1
  5. package/dist/page/client/account.js +430 -23
  6. package/dist/page/client/account.js.map +1 -1
  7. package/dist/page/client/base.js +20 -1
  8. package/dist/page/client/base.js.map +1 -1
  9. package/dist/page/client/composer.js +693 -54
  10. package/dist/page/client/composer.js.map +1 -1
  11. package/dist/page/client/computer.d.ts +27 -0
  12. package/dist/page/client/computer.js +358 -0
  13. package/dist/page/client/computer.js.map +1 -0
  14. package/dist/page/client/dialogs.js +93 -6
  15. package/dist/page/client/dialogs.js.map +1 -1
  16. package/dist/page/client/events.js +94 -7
  17. package/dist/page/client/events.js.map +1 -1
  18. package/dist/page/client/files.js +86 -1
  19. package/dist/page/client/files.js.map +1 -1
  20. package/dist/page/client/index.js +4 -0
  21. package/dist/page/client/index.js.map +1 -1
  22. package/dist/page/client/panels.js +10 -6
  23. package/dist/page/client/panels.js.map +1 -1
  24. package/dist/page/client/routines.js +296 -41
  25. package/dist/page/client/routines.js.map +1 -1
  26. package/dist/page/client/shell.js +187 -51
  27. package/dist/page/client/shell.js.map +1 -1
  28. package/dist/page/client/state.js +273 -19
  29. package/dist/page/client/state.js.map +1 -1
  30. package/dist/page/client/tasks.d.ts +8 -0
  31. package/dist/page/client/tasks.js +293 -0
  32. package/dist/page/client/tasks.js.map +1 -0
  33. package/dist/page/client/transcript.js +137 -5
  34. package/dist/page/client/transcript.js.map +1 -1
  35. package/dist/page/client/usage.js +1 -11
  36. package/dist/page/client/usage.js.map +1 -1
  37. package/dist/page/style.js +568 -60
  38. package/dist/page/style.js.map +1 -1
  39. package/dist/page.js +90 -11
  40. package/dist/page.js.map +1 -1
  41. package/dist/prefs.d.ts +14 -0
  42. package/dist/prefs.js +8 -1
  43. package/dist/prefs.js.map +1 -1
  44. package/dist/remote-crypto.d.ts +78 -0
  45. package/dist/remote-crypto.js +216 -0
  46. package/dist/remote-crypto.js.map +1 -0
  47. package/dist/remote.d.ts +103 -0
  48. package/dist/remote.js +498 -0
  49. package/dist/remote.js.map +1 -0
  50. package/dist/server.d.ts +70 -0
  51. package/dist/server.js +1291 -49
  52. package/dist/server.js.map +1 -1
  53. package/dist/starters.d.ts +92 -0
  54. package/dist/starters.js +213 -0
  55. package/dist/starters.js.map +1 -0
  56. package/dist/turn-shots.d.ts +27 -0
  57. package/dist/turn-shots.js +71 -0
  58. package/dist/turn-shots.js.map +1 -0
  59. package/package.json +2 -2
@@ -11,6 +11,7 @@ export const COMPOSER = String.raw `
11
11
  // stays small and a half-finished upload cannot start a turn.
12
12
 
13
13
  const attachEl = document.getElementById("attachments");
14
+ const trayEl = document.getElementById("tray");
14
15
  const queuedEl = document.getElementById("queued");
15
16
  const fileInput = document.getElementById("file");
16
17
  const composerBox = document.getElementById("composer-box");
@@ -30,6 +31,160 @@ const sentPreviews = new Map();
30
31
  */
31
32
  let refs = [];
32
33
  const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
34
+ /**
35
+ * Whether the next message is sent as a goal.
36
+ *
37
+ * Switched on from the + menu or a bare /goal, and off again once a message
38
+ * goes: a goal is one objective, and every message after it staying a goal
39
+ * would replace the one being worked on.
40
+ */
41
+ let goalMode = false;
42
+ /**
43
+ * Whether the next message starts a new session in a new worktree beside the
44
+ * project, instead of going to this one. Off again once it is sent, like a
45
+ * goal: it describes one message, not the conversation.
46
+ */
47
+ let worktreeMode = false;
48
+ const inputPlaceholder = () =>
49
+ t(goalMode ? "goal.placeholder" : worktreeMode ? "worktree.placeholder" : "composer.placeholder");
50
+
51
+ function setGoalMode(on) {
52
+ goalMode = on;
53
+ input.placeholder = inputPlaceholder();
54
+ renderAttachments();
55
+ }
56
+
57
+ function setWorktreeMode(on) {
58
+ worktreeMode = on;
59
+ input.placeholder = inputPlaceholder();
60
+ renderAttachments();
61
+ }
62
+
63
+ /**
64
+ * Make the worktree, open the session in it, and send it the message.
65
+ *
66
+ * The server does all three in one call so the first turn cannot start in the
67
+ * wrong tree; the page then moves to the new tab the way any switch does.
68
+ */
69
+ async function startInWorktree(body) {
70
+ const state = await api("/worktree", body, 5 * 60_000);
71
+ if (!state || state.error) return void addNotice((state && state.error) || t("worktree.failed"), "notice err");
72
+ await openState(state);
73
+ await loadProjects();
74
+ const wt = state.worktree || {};
75
+ addNotice(
76
+ wt.branch
77
+ ? t("worktree.startedBranch", { name: projectName(wt.root || wt.path), branch: wt.branch })
78
+ : t("worktree.started", { name: projectName(wt.root || wt.path) }),
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Computer use for the conversation on screen: off, or on with what the OS
84
+ * currently lets it do. Kept on the pane, from the server's state, because it
85
+ * belongs to the conversation rather than to the next message the way goal
86
+ * mode does.
87
+ */
88
+ const computerHere = () => {
89
+ const here = panes.get(activeId);
90
+ return (here && here.computer) || null;
91
+ };
92
+
93
+ function setComputerUse(on) {
94
+ track("computer_use", { on });
95
+ void api("/computer-use", { session: activeId, on }).then((s) => {
96
+ if (s && s.error) return void addNotice(s.error, "notice err");
97
+ applyState(s);
98
+ });
99
+ }
100
+
101
+ /** While the permission card is up, look again every two seconds. */
102
+ let computerPoll = null;
103
+ function watchComputerGrants(missing) {
104
+ if (missing && !computerPoll) {
105
+ computerPoll = setInterval(() => void api("/computer-use/permissions", {}), 2000);
106
+ } else if (!missing && computerPoll) {
107
+ clearInterval(computerPoll);
108
+ computerPoll = null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The macOS grants, asked for in place.
114
+ *
115
+ * Only on macOS and only for what is missing. Each row asks for one grant, so
116
+ * the system prompt that follows is about exactly the thing the row names.
117
+ */
118
+ function computerPermissionCard(computer) {
119
+ const perms = computer.permissions;
120
+ const card = el("div", "att computer-perm");
121
+ const head = el("div", "cp-head");
122
+ head.appendChild(svgIcon("computer"));
123
+ // The app whose switch it is: OHarness, or Electron for a development run.
124
+ const grantee = computer.grantee || "OHarness";
125
+ head.appendChild(el("span", "cp-title", t("computer.permTitle", { app: grantee })));
126
+ card.appendChild(head);
127
+ card.appendChild(el("div", "cp-body", t(grantee === "OHarness" ? "computer.permBody" : "computer.permBodyDev", { app: grantee })));
128
+ for (const [grant, label] of [["screen", "computer.permScreen"], ["accessibility", "computer.permAx"]]) {
129
+ const row = el("div", "cp-row");
130
+ row.appendChild(el("span", "cp-label", t(label)));
131
+ if (perms[grant]) {
132
+ // A tick, not a word in green: done is the quiet state, and the row's
133
+ // own label already says what was allowed.
134
+ const ok = el("span", "cp-ok", "\u2713");
135
+ ok.title = t("computer.granted");
136
+ ok.setAttribute("aria-label", t("computer.granted"));
137
+ row.appendChild(ok);
138
+ } else {
139
+ const allow = el("button", "cp-btn", t("computer.allow"));
140
+ allow.type = "button";
141
+ allow.addEventListener("click", () => void api("/computer-use/permissions", { request: true, grant }));
142
+ row.appendChild(allow);
143
+ }
144
+ card.appendChild(row);
145
+ }
146
+ const foot = el("div", "cp-foot");
147
+ const again = el("button", "cp-link", t("computer.recheck"));
148
+ again.type = "button";
149
+ again.addEventListener("click", () => void api("/computer-use/permissions", {}));
150
+ foot.appendChild(again);
151
+ if (computer.relaunch) {
152
+ const relaunch = el("button", "cp-link", t("computer.relaunch"));
153
+ relaunch.type = "button";
154
+ relaunch.title = t("computer.relaunchHint");
155
+ relaunch.addEventListener("click", () => void api("/computer-use/relaunch", {}));
156
+ foot.appendChild(relaunch);
157
+ }
158
+ card.appendChild(foot);
159
+ return card;
160
+ }
161
+
162
+ /** The tab's permission mode, as its state last said. */
163
+ function currentMode() {
164
+ const here = panes.get(activeId);
165
+ return (here && here.mode) || "default";
166
+ }
167
+
168
+ /**
169
+ * Into plan mode, and back to whatever it was before — not to Manual, which
170
+ * would quietly undo an Accept edits or Auto somebody had chosen.
171
+ */
172
+ function togglePlanMode() {
173
+ const here = panes.get(activeId);
174
+ if (!here) return;
175
+ const leaving = currentMode() === "plan";
176
+ const next = leaving ? here.beforePlan || "default" : "plan";
177
+ if (!leaving) here.beforePlan = currentMode();
178
+ track("plan_mode", { on: !leaving });
179
+ void api("/mode", { session: activeId, mode: next }).then(applyState);
180
+ }
181
+
182
+ /** Send text as the session's goal: the first message, and the finish line. */
183
+ function sendGoal(text) {
184
+ if (!text) return void addNotice(t("goal.needsText"), "notice err");
185
+ track("goal_set", {});
186
+ void api("/prompt", { session: activeId, prompt: text, attachments: [], references: [], goal: true }).then(pushNotice);
187
+ }
33
188
  const refKey = (ref) => ref.kind + ":" + ref.value;
34
189
 
35
190
  /**
@@ -46,6 +201,22 @@ function renderQueued() {
46
201
  // tab is the one streaming.
47
202
  const here = panes.get(activeId);
48
203
  const waiting = (here && here.queued) || [];
204
+ // The goal being worked toward sits over the queue: it is the reason the
205
+ // turn below it keeps going, and the × is the way to let it stop.
206
+ if (here && here.goal) {
207
+ const row = el("div", "q goal");
208
+ row.appendChild(el("span", "tag", t("goal.tag")));
209
+ row.appendChild(el("span", "n", here.goal));
210
+ const stop = el("button", "x", "×");
211
+ stop.type = "button";
212
+ stop.title = t("goal.stop");
213
+ stop.addEventListener("click", () => void api("/goal", { session: activeId }).then(applyState));
214
+ row.appendChild(stop);
215
+ queuedEl.appendChild(row);
216
+ }
217
+ // Background tasks next: work this conversation set going, still part of
218
+ // what is happening around the message being written.
219
+ appendTaskRows(queuedEl, here);
49
220
  for (const entry of waiting) {
50
221
  const row = el("div", "q" + (entry.soon ? " soon" : ""));
51
222
  // Two different promises: one joins the turn that is running, the other
@@ -81,47 +252,218 @@ function renderQueued() {
81
252
  function renderAttachments() {
82
253
  attachEl.replaceChildren();
83
254
 
84
- // References first: they were picked deliberately from the menu, while an
85
- // upload is often a screenshot that was pasted without being thought about.
255
+ if (goalMode) {
256
+ const chip = el("div", "att goal");
257
+ chip.appendChild(svgIcon("goal"));
258
+ chip.appendChild(el("span", "n", t("goal.chip")));
259
+ const drop = el("button", "x", "×");
260
+ drop.type = "button";
261
+ drop.title = t("add.remove");
262
+ drop.addEventListener("click", () => setGoalMode(false));
263
+ chip.appendChild(drop);
264
+ attachEl.appendChild(chip);
265
+ }
266
+
267
+ if (worktreeMode) {
268
+ const chip = el("div", "att goal");
269
+ chip.appendChild(svgIcon("worktree"));
270
+ chip.appendChild(el("span", "n", t("worktree.chip")));
271
+ const drop = el("button", "x", "×");
272
+ drop.type = "button";
273
+ drop.title = t("add.remove");
274
+ drop.addEventListener("click", () => setWorktreeMode(false));
275
+ chip.appendChild(drop);
276
+ attachEl.appendChild(chip);
277
+ }
278
+
279
+ // Computer use is a state of the conversation, so its chip stays until it is
280
+ // switched off, and says what is missing when the OS has not allowed it yet.
281
+ const computer = computerHere();
282
+ let missing = false;
283
+ if (computer && computer.on) {
284
+ const chip = el("div", "att computer");
285
+ chip.appendChild(svgIcon("computer"));
286
+ const label = el("span", "n", t("computer.chip"));
287
+ // The chip is also the way back to the live card once it was folded.
288
+ label.classList.add("clickable");
289
+ label.title = t("computer.showPane");
290
+ label.addEventListener("click", computerShow);
291
+ chip.appendChild(label);
292
+ const drop = el("button", "x", "×");
293
+ drop.type = "button";
294
+ drop.title = t("computer.off");
295
+ drop.addEventListener("click", () => setComputerUse(false));
296
+ chip.appendChild(drop);
297
+ attachEl.appendChild(chip);
298
+ const perms = computer.permissions;
299
+ missing = Boolean(computer.platform === "darwin" && perms && (!perms.screen || !perms.accessibility));
300
+ if (missing) attachEl.appendChild(computerPermissionCard(computer));
301
+ else if (computer.error) attachEl.appendChild(el("div", "att computer-perm cp-body", t("computer.unavailable", { error: computer.error })));
302
+ }
303
+ watchComputerGrants(missing);
304
+
305
+ // Capabilities named for this message — a connector, a plugin, a skill —
306
+ // stay in the box beside the goal and Computer use: they are how it is
307
+ // sent. What it carries goes in the tray above.
86
308
  for (const ref of refs) {
309
+ if (ref.kind === "file" || ref.kind === "folder") continue;
87
310
  const chip = el("div", "att");
88
311
  chip.appendChild(svgIcon(REF_ICON[ref.kind]));
89
312
  chip.appendChild(el("span", "n", ref.label));
90
313
  if (ref.sub) chip.appendChild(el("span", "where", ref.sub));
91
- const drop = el("button", "x", "×");
92
- drop.type = "button";
93
- drop.title = t("add.remove");
94
- drop.addEventListener("click", () => {
314
+ chip.appendChild(dropButton(() => {
95
315
  refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
96
316
  renderAttachments();
97
- });
98
- chip.appendChild(drop);
317
+ }));
99
318
  attachEl.appendChild(chip);
100
319
  }
320
+ renderTray();
321
+ }
101
322
 
323
+ /**
324
+ * What the message carries, above the box rather than in it.
325
+ *
326
+ * Inside, a screenshot was a 26px square beside its file name, and a row of
327
+ * them pushed the text being written down the box. Out here each is a card
328
+ * of its own: an image or a video as a picture of itself, a file or a folder
329
+ * as its name and where it is. Files and folders picked from the disk first —
330
+ * picked on purpose — then what was pasted or dropped.
331
+ */
332
+ function renderTray() {
333
+ trayEl.replaceChildren();
334
+ for (const ref of refs) {
335
+ if (ref.kind !== "file" && ref.kind !== "folder") continue;
336
+ const media = ref.kind === "file" ? mediaKindOf(ref.value) : "";
337
+ const cwd = P && P.cwd;
338
+ // Inside the project the file browser serves it; outside, only a file
339
+ // picked in the dialog can be shown, from the server's list of those.
340
+ const outside = /^(\/|[A-Za-z]:[\\/])/.test(ref.value);
341
+ const src = !media ? "" : outside
342
+ ? q("/pick/preview?path=" + encodeURIComponent(ref.value))
343
+ : cwd ? q("/files/raw?cwd=" + encodeURIComponent(cwd) + "&path=" + encodeURIComponent(ref.value)) : "";
344
+ trayEl.appendChild(trayCard({
345
+ media: src ? media : "",
346
+ src,
347
+ icon: REF_ICON[ref.kind],
348
+ name: ref.label,
349
+ sub: ref.sub || "",
350
+ remove: () => {
351
+ refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
352
+ renderAttachments();
353
+ },
354
+ }));
355
+ }
102
356
  for (const file of staged) {
103
- const chip = el("div", "att");
104
- if (file.preview) {
105
- const img = document.createElement("img");
106
- img.src = file.preview;
107
- img.alt = "";
108
- chip.appendChild(img);
109
- }
110
- chip.appendChild(el("span", "n", file.name));
111
- chip.appendChild(el("span", "sz", (file.bytes / 1024).toFixed(0) + "KB"));
112
- const remove = el("button", "x", "×");
113
- remove.type = "button";
114
- remove.addEventListener("click", () => {
115
- staged = staged.filter((entry) => entry.id !== file.id);
116
- // The server is holding these bytes for a prompt that is no longer
117
- // going to name them, and it has no other way to learn that.
118
- void api("/upload/drop", { id: file.id });
119
- if (file.preview) URL.revokeObjectURL(file.preview);
120
- renderAttachments();
357
+ const media = file.preview ? file.previewKind || "image" : "";
358
+ trayEl.appendChild(trayCard({
359
+ media,
360
+ src: file.preview || "",
361
+ icon: "file",
362
+ name: file.name,
363
+ sub: formatBytes(file.bytes),
364
+ remove: () => {
365
+ staged = staged.filter((entry) => entry.id !== file.id);
366
+ // The server is holding these bytes for a prompt that is no longer
367
+ // going to name them, and it has no other way to learn that.
368
+ void api("/upload/drop", { id: file.id });
369
+ if (file.preview) URL.revokeObjectURL(file.preview);
370
+ renderAttachments();
371
+ },
372
+ }));
373
+ }
374
+ }
375
+
376
+ /** An image or a video can be shown as itself; anything else is a name. */
377
+ function mediaKindOf(name) {
378
+ const ext = (String(name).split(".").pop() || "").toLowerCase();
379
+ if (["png", "jpg", "jpeg", "gif", "webp", "avif"].includes(ext)) return "image";
380
+ if (["mp4", "webm", "mov", "m4v"].includes(ext)) return "video";
381
+ return "";
382
+ }
383
+
384
+ function formatBytes(bytes) {
385
+ if (!bytes && bytes !== 0) return "";
386
+ if (bytes < 1024 * 1024) return Math.max(1, Math.round(bytes / 1024)) + " KB";
387
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
388
+ }
389
+
390
+ function dropButton(onDrop) {
391
+ const drop = el("button", "x", "×");
392
+ drop.type = "button";
393
+ drop.title = t("add.remove");
394
+ drop.setAttribute("aria-label", t("add.remove"));
395
+ drop.addEventListener("click", (e) => {
396
+ e.stopPropagation();
397
+ onDrop();
398
+ });
399
+ return drop;
400
+ }
401
+
402
+ /**
403
+ * One card in the tray. A picture is the card — click it for the full size,
404
+ * the same preview the conversation uses — and a video plays muted on hover,
405
+ * so which clip it is can be told without opening it. A file is its icon, its
406
+ * name and a line under it.
407
+ */
408
+ function trayCard({ media, src, icon, name, sub, remove }) {
409
+ const card = el("div", "tray-card" + (media === "image" ? " tray-media image" : media === "video" ? " video" : ""));
410
+ card.title = name;
411
+ if (media === "image") {
412
+ const img = document.createElement("img");
413
+ img.src = src;
414
+ img.alt = name;
415
+ img.addEventListener("click", () => previewImage(img));
416
+ // A preview that will not load is a file card, not a broken picture.
417
+ img.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
418
+ card.appendChild(img);
419
+ } else if (media === "video") {
420
+ // A clip keeps its name and size beside it: one frame of a video says
421
+ // less about which file it is than a photo does.
422
+ const thumb = el("span", "tray-thumb");
423
+ const video = document.createElement("video");
424
+ video.src = src;
425
+ video.muted = true;
426
+ video.preload = "metadata";
427
+ video.playsInline = true;
428
+ video.loop = true;
429
+ // The first frame rather than black, which is what a clip shows before it plays.
430
+ video.addEventListener("loadedmetadata", () => {
431
+ try {
432
+ video.currentTime = Math.min(0.1, video.duration || 0);
433
+ } catch {
434
+ // Not seekable: its poster frame is whatever it shows.
435
+ }
436
+ if (Number.isFinite(video.duration) && video.duration > 0) {
437
+ const s = Math.round(video.duration);
438
+ duration.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
439
+ }
121
440
  });
122
- chip.appendChild(remove);
123
- attachEl.appendChild(chip);
441
+ card.addEventListener("mouseenter", () => void video.play().catch(() => {}));
442
+ card.addEventListener("mouseleave", () => video.pause());
443
+ video.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
444
+ thumb.appendChild(video);
445
+ const play = el("span", "tray-play");
446
+ play.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.8v8.4L12 8z" fill="currentColor"/></svg>';
447
+ thumb.appendChild(play);
448
+ card.appendChild(thumb);
449
+ const text = el("span", "tray-text");
450
+ text.appendChild(el("span", "tray-name", name));
451
+ const line = el("span", "tray-sub");
452
+ const duration = el("span", "tray-duration");
453
+ line.append(duration, document.createTextNode(sub ? (sub) : ""));
454
+ text.appendChild(line);
455
+ card.appendChild(text);
456
+ } else {
457
+ const mark = el("span", "tray-icon");
458
+ mark.appendChild(svgIcon(icon));
459
+ card.appendChild(mark);
460
+ const text = el("span", "tray-text");
461
+ text.appendChild(el("span", "tray-name", name));
462
+ if (sub) text.appendChild(el("span", "tray-sub", sub));
463
+ card.appendChild(text);
124
464
  }
465
+ card.appendChild(dropButton(remove));
466
+ return card;
125
467
  }
126
468
 
127
469
  async function upload(file) {
@@ -135,6 +477,10 @@ async function upload(file) {
135
477
 
136
478
  // A local preview rather than a round trip: the bytes are already here.
137
479
  if (body.kind === "image") body.preview = URL.createObjectURL(file);
480
+ else if ((file.type || "").startsWith("video/")) {
481
+ body.preview = URL.createObjectURL(file);
482
+ body.previewKind = "video";
483
+ }
138
484
  staged.push(body);
139
485
  renderAttachments();
140
486
  }
@@ -414,16 +760,35 @@ function attachRows() {
414
760
  const plugins = (offer.plugins || []).length;
415
761
  const skills = (offer.skills || []).length;
416
762
  return [
417
- // Two rows, because the dialogs behind them are two: only macOS has a
418
- // panel that takes a file and a folder in one pass, and everywhere else a
419
- // single row promising both could open only one of them. Saying which one
420
- // opens before it opens is also how someone reaching for a screenshot
421
- // stops reading at the first row.
763
+ // What goes with the message: from this machine, first. Two rows for
764
+ // files and folders, because the dialogs behind them are two: only macOS
765
+ // has a panel that takes a file and a folder in one pass, and everywhere
766
+ // else a single row promising both could open only one of them. Saying
767
+ // which one opens before it opens is also how someone reaching for a
768
+ // screenshot stops reading at the first row.
769
+ { type: "group", label: t("add.sectionAttach") },
422
770
  { type: "pick", kind: "files", icon: "file", label: t("add.attachFiles") },
423
771
  { type: "pick", kind: "folder", icon: "folder", label: t("add.attachFolders") },
424
- // A count when there is one, and nothing when there is not: "none" is a
425
- // word in the slot that otherwise holds a number, and it reads as a value
426
- // rather than as an absence.
772
+ { type: "group", label: t("add.sectionMode") },
773
+ // The permission mode of the same name, from the place people look for a
774
+ // way of working. The picker by the send button still holds all five.
775
+ { type: "plan", icon: "modePlan", label: t("add.planMode"), on: currentMode() === "plan" },
776
+ // How the agent works on it: a way of sending, and a capability of the
777
+ // conversation, rather than things sent.
778
+ { type: "goal", icon: "goal", label: t("add.goal"), on: goalMode },
779
+ // Where it happens: a tree of its own beside this one, for work that
780
+ // should not touch what is being edited here.
781
+ { type: "worktree", icon: "worktree", label: t("add.worktree"), on: worktreeMode },
782
+ // Only where there is a computer to use: the desktop app, whose server
783
+ // reports the feature in the conversation's state.
784
+ ...(IS_DESKTOP && computerHere()
785
+ ? [{ type: "computer", icon: "computer", label: t("add.computerUse"), on: Boolean(computerHere().on) }]
786
+ : []),
787
+ { type: "group", label: t("add.sectionReach") },
788
+ // What it can reach, each a list of its own. A count when there is one,
789
+ // and nothing when there is not: "none" is a word in the slot that
790
+ // otherwise holds a number, and it reads as a value rather than as an
791
+ // absence.
427
792
  {
428
793
  type: "into",
429
794
  kind: "connectors",
@@ -448,21 +813,29 @@ function attachRows() {
448
813
  sub: skills ? String(skills) : "",
449
814
  into: true,
450
815
  },
451
- { type: "upload", icon: "upload", label: t("add.upload") },
452
816
  ];
453
817
  }
454
818
 
819
+ /** Headings and dividers: in the list, but not something to pick. */
820
+ function notChoice(row) {
821
+ return row.type === "group" || row.type === "sep";
822
+ }
823
+
455
824
  function paintAttachMenu() {
456
825
  const rows = attachRows();
457
826
  if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
458
827
  // The list opens with the cursor at 0, and 0 is a heading whenever the list
459
828
  // is grouped. Nudged here rather than at every call site that sets it.
460
- while (rows[attachCursor] && rows[attachCursor].type === "group" && attachCursor < rows.length - 1) {
829
+ while (rows[attachCursor] && notChoice(rows[attachCursor]) && attachCursor < rows.length - 1) {
461
830
  attachCursor++;
462
831
  }
463
832
  attachBody.replaceChildren();
464
833
 
465
834
  rows.forEach((row, i) => {
835
+ if (row.type === "sep") {
836
+ attachBody.appendChild(el("div", "menu-sep"));
837
+ return;
838
+ }
466
839
  if (row.type === "group") {
467
840
  attachBody.appendChild(el("div", "menu-head", row.label));
468
841
  return;
@@ -473,7 +846,7 @@ function paintAttachMenu() {
473
846
  }
474
847
  const item = el(
475
848
  "div",
476
- "menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
849
+ "menu-item" + ((row.ref && referenced(row.ref)) || row.on ? " on" : "") + (i === attachCursor ? " cursor" : ""),
477
850
  );
478
851
  item.setAttribute("role", "button");
479
852
  item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
@@ -491,7 +864,7 @@ function paintAttachMenu() {
491
864
  // is chosen still held a column open in front of every row that was not,
492
865
  // which put the icons a third of the way into the menu and the words
493
866
  // further still. On the right it costs nothing until there is one.
494
- if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
867
+ if ((row.ref && referenced(row.ref)) || row.on) item.appendChild(el("span", "tick", "✓"));
495
868
  if (row.into) item.appendChild(el("span", "chev"));
496
869
  // An installed connector is removed from the row that says it is
497
870
  // installed, which is the row someone looking to remove it goes to.
@@ -510,7 +883,7 @@ function paintAttachMenu() {
510
883
  });
511
884
 
512
885
  // One line saying why a screen is empty, rather than an empty screen.
513
- const listed = rows.filter((row) => row.type !== "up").length;
886
+ const listed = rows.filter((row) => row.type !== "up" && row.type !== "sep").length;
514
887
  if (attachView === "connectors" && !listed) {
515
888
  attachBody.appendChild(el("div", "menu-hint", t("add.noConnectors")));
516
889
  } else if (attachView === "plugins" && !listed) {
@@ -524,7 +897,7 @@ function paintAttachMenu() {
524
897
  }
525
898
 
526
899
  function chooseAttachRow(row) {
527
- if (row.type === "hint") return;
900
+ if (row.type === "hint" || notChoice(row)) return;
528
901
  if (row.type === "up") return void showAttachView("root");
529
902
  if (row.type === "into") return void showAttachView(row.kind);
530
903
  if (row.type === "offer-ref") {
@@ -549,11 +922,27 @@ function chooseAttachRow(row) {
549
922
  closeAttachMenu();
550
923
  return void openMarket(row.kind);
551
924
  }
552
- if (row.type === "upload") {
925
+ if (row.type === "command") return void pickCommand(row.command);
926
+ if (row.type === "goal") {
553
927
  closeAttachMenu();
554
- return void fileInput.click();
928
+ setGoalMode(!goalMode);
929
+ return void input.focus();
930
+ }
931
+ if (row.type === "worktree") {
932
+ closeAttachMenu();
933
+ setWorktreeMode(!worktreeMode);
934
+ return void input.focus();
935
+ }
936
+ if (row.type === "plan") {
937
+ closeAttachMenu();
938
+ togglePlanMode();
939
+ return void input.focus();
940
+ }
941
+ if (row.type === "computer") {
942
+ closeAttachMenu();
943
+ setComputerUse(!row.on);
944
+ return void input.focus();
555
945
  }
556
- if (row.type === "command") return void insertCommand(row.command);
557
946
  if (attachView === "mention" && row.ref) {
558
947
  return void insertMention(row.ref.value);
559
948
  }
@@ -579,7 +968,14 @@ async function pickFromDisk(kind) {
579
968
  const picked = await api("/pick", kind === "folder" ? {} : { kind: "files" }, 5 * 60_000);
580
969
  // A dismissed dialog is an answer, and the only one that says nothing.
581
970
  if (picked.cancelled) return;
582
- if (picked.unavailable || picked.error) return void addNotice(t("add.noDialog"), "err");
971
+ // No dialog on this machine (a browser on another device, a desktop with no
972
+ // picker): the browser's own file chooser, which carries the bytes. It was a
973
+ // row of its own, "Upload from computer", beside this one — two rows for
974
+ // one thing, told apart only by what happened afterwards.
975
+ if (picked.unavailable || picked.error) {
976
+ if (kind === "folder") return void addNotice(t("add.noDialog"), "err");
977
+ return void fileInput.click();
978
+ }
583
979
 
584
980
  if (kind === "folder") {
585
981
  // Relative when it is inside the project, which is shorter to read and is
@@ -618,11 +1014,12 @@ async function pickFromDisk(kind) {
618
1014
  * something the server had already forgotten.
619
1015
  */
620
1016
  async function withPreview(file) {
621
- if (file.kind !== "image") return file;
1017
+ const video = String(file.mediaType || "").startsWith("video/");
1018
+ if (file.kind !== "image" && !video) return file;
622
1019
  try {
623
1020
  const response = await fetch(q("/upload/preview?id=" + encodeURIComponent(file.id)));
624
1021
  if (!response.ok) return file;
625
- return { ...file, preview: URL.createObjectURL(await response.blob()) };
1022
+ return { ...file, preview: URL.createObjectURL(await response.blob()), ...(video ? { previewKind: "video" } : {}) };
626
1023
  } catch {
627
1024
  // A chip without a thumbnail is still a chip; nothing here is worth a
628
1025
  // notice the person cannot act on.
@@ -1057,7 +1454,7 @@ function moveAttachCursor(e) {
1057
1454
  let next = attachCursor;
1058
1455
  for (let i = 0; i < rows.length; i++) {
1059
1456
  next = (next + step + rows.length) % rows.length;
1060
- if (rows[next].type !== "group") break;
1457
+ if (!notChoice(rows[next])) break;
1061
1458
  }
1062
1459
  attachCursor = next;
1063
1460
  paintAttachMenu();
@@ -1137,6 +1534,14 @@ input.addEventListener("keydown", (e) => {
1137
1534
  * what a reader is checking when they glance at it.
1138
1535
  */
1139
1536
  function paintMirror() {
1537
+ paintMarks();
1538
+ // Every caller has just rewritten the text, and a rewrite from code — the
1539
+ // box emptied on send, a command inserted — fires no input event, so the
1540
+ // drawn caret would stay where the old text put it.
1541
+ placeCaret();
1542
+ }
1543
+
1544
+ function paintMarks() {
1140
1545
  if (!inputMirror) return;
1141
1546
  // Typing an @ asks for the list, but text can also arrive whole — pasted, or
1142
1547
  // restored as a draft — and that text has mentions in it too. Guarded, so
@@ -1310,6 +1715,24 @@ const COMMANDS = [
1310
1715
  return void api("/routine", { name: arg }).then(pushNotice);
1311
1716
  },
1312
1717
  },
1718
+ {
1719
+ name: "goal",
1720
+ arg: "goal",
1721
+ hint: () => t("cmd.goal"),
1722
+ run: (arg) => {
1723
+ // Bare, it switches the composer into goal mode for the next message;
1724
+ // "clear" lets go of the one being worked toward.
1725
+ if (!arg) return void setGoalMode(true);
1726
+ if (/^(clear|off|stop)$/i.test(arg)) return void api("/goal", { session: activeId }).then(applyState);
1727
+ sendGoal(arg);
1728
+ },
1729
+ },
1730
+ {
1731
+ name: "compact",
1732
+ arg: "",
1733
+ hint: () => t("cmd.compact"),
1734
+ run: () => compactConversation(),
1735
+ },
1313
1736
  {
1314
1737
  name: "new",
1315
1738
  arg: "",
@@ -1318,6 +1741,18 @@ const COMMANDS = [
1318
1741
  },
1319
1742
  ];
1320
1743
 
1744
+ /** /compact, and the context panel's button: summarize the conversation now. */
1745
+ function compactConversation() {
1746
+ addNotice(t("cmd.compacting"), "notice");
1747
+ // A summary is a model call over the whole conversation, well past the
1748
+ // half-minute a settings toggle is given.
1749
+ api("/compact", { session: activeId }, 180000).then((body) => {
1750
+ if (body && body.error) return addNotice(body.error, "notice err");
1751
+ // A compaction posts its own notice; only the nothing-to-do case needs one.
1752
+ if (body && body.compacted === false) addNotice(t("cmd.nothingToCompact"), "notice");
1753
+ });
1754
+ }
1755
+
1321
1756
  function pushNotice(body) {
1322
1757
  if (body && body.error) addNotice(body.error, "notice err");
1323
1758
  }
@@ -1346,6 +1781,23 @@ function openCommands(command) {
1346
1781
  paintAttachMenu();
1347
1782
  }
1348
1783
 
1784
+ /**
1785
+ * A command chosen from the menu.
1786
+ *
1787
+ * One that takes nothing runs at once: inserting "/new " only to need a second
1788
+ * Enter is a step that decides nothing. One that takes an argument is written
1789
+ * into the box, ready for it.
1790
+ */
1791
+ function pickCommand(name) {
1792
+ const command = COMMANDS.find((one) => one.name === name);
1793
+ if (!command || command.arg) return void insertCommand(name);
1794
+ input.value = "";
1795
+ autosize();
1796
+ paintMirror();
1797
+ closeAttachMenu();
1798
+ command.run("");
1799
+ }
1800
+
1349
1801
  /** Insert the command, with the space its argument starts after. */
1350
1802
  function insertCommand(name) {
1351
1803
  const rest = input.value.slice(input.selectionStart);
@@ -1463,7 +1915,12 @@ input.addEventListener("input", () => {
1463
1915
  // Before the composer's own Enter handling, which would otherwise send the
1464
1916
  // message out from under a menu the user was choosing from.
1465
1917
  input.addEventListener("keydown", (e) => {
1466
- if (attachView !== "mention" || attachMenu.hidden) return;
1918
+ if ((attachView !== "mention" && attachView !== "command") || attachMenu.hidden) return;
1919
+ // A slash word no command starts with is prose — /etc, and/or — so with
1920
+ // nothing to pick, Enter goes on to send it rather than choosing nothing.
1921
+ if (attachView === "command" && (e.key === "Enter" || e.key === "Tab") && !attachRows().length) {
1922
+ return void closeAttachMenu();
1923
+ }
1467
1924
  if (e.key === "Escape") {
1468
1925
  e.stopImmediatePropagation();
1469
1926
  return void closeAttachMenu();
@@ -1524,23 +1981,43 @@ document.getElementById("composer").addEventListener("submit", (e) => {
1524
1981
  const ids = staged.map((file) => file.id);
1525
1982
  const shots = staged
1526
1983
  .filter((file) => file.preview)
1527
- .map((file) => ({ src: file.preview, name: file.name }));
1984
+ .map((file) => ({ src: file.preview, name: file.name, kind: file.previewKind || "image" }));
1528
1985
  if (shots.length) sentPreviews.set(activeId, shots);
1529
1986
  const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
1987
+ const asGoal = goalMode;
1988
+ const asWorktree = worktreeMode;
1989
+ if (asGoal && !text) return void addNotice(t("goal.needsText"), "notice err");
1990
+ if (asWorktree && !text) return void addNotice(t("worktree.placeholder"), "notice err");
1530
1991
  input.value = "";
1531
1992
  autosize();
1532
1993
  paintMirror();
1533
1994
  staged = [];
1534
1995
  refs = [];
1996
+ if (asGoal) setGoalMode(false);
1997
+ if (asWorktree) setWorktreeMode(false);
1535
1998
  renderAttachments();
1536
1999
  // The counts, never the text, the file names or the paths.
1537
- track("prompt_sent", { attachments: ids.length, references: referenced.length });
2000
+ track("prompt_sent", { attachments: ids.length, references: referenced.length, goal: asGoal, worktree: asWorktree });
2001
+ if (asWorktree) {
2002
+ // From the project the tab on screen is in, named after the first line.
2003
+ const here = panes.get(activeId);
2004
+ const task = text.split("\n")[0].trim().slice(0, 80);
2005
+ return void startInWorktree({
2006
+ ...(here && here.cwd ? { cwd: here.cwd } : {}),
2007
+ task,
2008
+ prompt: text,
2009
+ attachments: ids,
2010
+ references: referenced,
2011
+ ...(asGoal ? { goal: true } : {}),
2012
+ });
2013
+ }
1538
2014
  void api("/prompt", {
1539
2015
  session: activeId,
1540
2016
  prompt: text,
1541
2017
  attachments: ids,
1542
2018
  references: referenced,
1543
- });
2019
+ ...(asGoal ? { goal: true } : {}),
2020
+ }).then(pushNotice);
1544
2021
  });
1545
2022
 
1546
2023
  input.addEventListener("keydown", (e) => {
@@ -1552,7 +2029,6 @@ input.addEventListener("keydown", (e) => {
1552
2029
  input.addEventListener("input", () => {
1553
2030
  autosize();
1554
2031
  paintMirror();
1555
- placeCaret();
1556
2032
  });
1557
2033
 
1558
2034
  // --- the field's height ---------------------------------------------------
@@ -1722,5 +2198,168 @@ if (window.visualViewport) {
1722
2198
  window.addEventListener("resize", followKeyboard);
1723
2199
  }
1724
2200
 
2201
+ // ── Dictation ────────────────────────────────────────────────────────────
2202
+ // The mic records in the browser (MediaRecorder), the server turns it into
2203
+ // text (POST /voice/transcribe: the account's gateway, or the user's own
2204
+ // OpenAI key), and the words go into the box at the end of what is there.
2205
+ // Then POST /voice/tidy cleans them up — on a paid plan, or with the user's
2206
+ // own small model — and swaps the clean version in, unless the box was
2207
+ // touched meanwhile. Five minutes at most.
2208
+ //
2209
+ // What is going on shows in a panel floating above the box by the mic:
2210
+ // listening (the voice as bars, the time, cancel and done), working
2211
+ // (transcribing, tidying), or what went wrong. Esc cancels, Enter finishes.
2212
+ const micBtn = document.getElementById("mic");
2213
+ const pop = document.getElementById("dictate-pop");
2214
+ const popText = pop.querySelector(".dp-text");
2215
+ const popTime = pop.querySelector(".dp-time");
2216
+ const popWave = pop.querySelector(".dp-wave");
2217
+ let dictation = null;
2218
+ let popTimer = 0;
2219
+
2220
+ function dictateShow(state, text) {
2221
+ clearTimeout(popTimer);
2222
+ if (!state) { pop.hidden = true; pop.removeAttribute("data-state"); return; }
2223
+ pop.dataset.state = state;
2224
+ popText.textContent = text || "";
2225
+ pop.hidden = false;
2226
+ if (state === "error") popTimer = setTimeout(() => dictateShow(""), 8000);
2227
+ }
2228
+
2229
+ /** The voice, as bars: an analyser on the microphone, drawn every frame. */
2230
+ function drawWave(session) {
2231
+ const ctx = popWave.getContext("2d");
2232
+ const ratio = window.devicePixelRatio || 1;
2233
+ popWave.width = 168 * ratio; popWave.height = 32 * ratio;
2234
+ const bins = new Uint8Array(session.analyser.fftSize);
2235
+ const bars = 28;
2236
+ const history = new Array(bars).fill(0);
2237
+ const color = getComputedStyle(popWave).color;
2238
+ const frame = () => {
2239
+ if (dictation !== session) return;
2240
+ session.analyser.getByteTimeDomainData(bins);
2241
+ let peak = 0;
2242
+ for (const value of bins) peak = Math.max(peak, Math.abs(value - 128) / 128);
2243
+ history.shift(); history.push(Math.min(1, peak * 2.4));
2244
+ ctx.clearRect(0, 0, popWave.width, popWave.height);
2245
+ ctx.fillStyle = color;
2246
+ const step = popWave.width / bars, width = Math.max(2 * ratio, step * 0.45);
2247
+ history.forEach((level, index) => {
2248
+ const height = Math.max(3 * ratio, level * popWave.height);
2249
+ ctx.globalAlpha = 0.35 + 0.65 * (index / bars);
2250
+ ctx.fillRect(index * step + (step - width) / 2, (popWave.height - height) / 2, width, height);
2251
+ });
2252
+ session.frame = requestAnimationFrame(frame);
2253
+ };
2254
+ frame();
2255
+ }
2256
+
2257
+ async function startDictation() {
2258
+ // The first time, macOS asks before the browser answers, and the mic would
2259
+ // sit there looking dead until someone clicks Allow: say so meanwhile.
2260
+ dictateShow("working", t("dictate.opening"));
2261
+ micBtn.disabled = true;
2262
+ let stream;
2263
+ try {
2264
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
2265
+ } catch {
2266
+ micBtn.disabled = false;
2267
+ return dictateShow("error", t("dictate.denied"));
2268
+ }
2269
+ micBtn.disabled = false;
2270
+ const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
2271
+ .find((type) => MediaRecorder.isTypeSupported(type));
2272
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
2273
+ const chunks = [];
2274
+ recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
2275
+ const audio = new AudioContext();
2276
+ const analyser = audio.createAnalyser();
2277
+ analyser.fftSize = 512;
2278
+ audio.createMediaStreamSource(stream).connect(analyser);
2279
+ const session = { recorder, stream, chunks, audio, analyser, startedAt: Date.now(), timer: 0, frame: 0, cancelled: false };
2280
+ recorder.onstop = () => finishDictation(session);
2281
+ dictation = session;
2282
+ recorder.start(1000);
2283
+ micBtn.classList.add("is-recording");
2284
+ micBtn.setAttribute("aria-pressed", "true");
2285
+ micBtn.title = t("composer.dictateStop");
2286
+ dictateShow("listening", t("dictate.listening"));
2287
+ const tick = () => {
2288
+ const seconds = Math.floor((Date.now() - session.startedAt) / 1000);
2289
+ popTime.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
2290
+ if (seconds >= 300) stopDictation(false);
2291
+ };
2292
+ tick();
2293
+ session.timer = setInterval(tick, 500);
2294
+ drawWave(session);
2295
+ }
2296
+
2297
+ /** Done (keep what was said) or cancelled (throw it away). */
2298
+ function stopDictation(cancel) {
2299
+ const session = dictation;
2300
+ if (!session) return;
2301
+ dictation = null;
2302
+ session.cancelled = cancel;
2303
+ clearInterval(session.timer);
2304
+ cancelAnimationFrame(session.frame);
2305
+ if (session.recorder.state !== "inactive") session.recorder.stop();
2306
+ session.stream.getTracks().forEach((track) => track.stop());
2307
+ session.audio.close().catch(() => {});
2308
+ micBtn.classList.remove("is-recording");
2309
+ micBtn.setAttribute("aria-pressed", "false");
2310
+ micBtn.title = t("composer.dictate");
2311
+ if (cancel) dictateShow("");
2312
+ }
2313
+
2314
+ async function finishDictation(session) {
2315
+ if (session.cancelled) return;
2316
+ const duration = (Date.now() - session.startedAt) / 1000;
2317
+ const blob = new Blob(session.chunks, { type: session.recorder.mimeType || "audio/webm" });
2318
+ if (duration < 0.6 || !blob.size) return dictateShow("");
2319
+ const format = /mp4/.test(blob.type) ? "m4a" : /ogg/.test(blob.type) ? "ogg" : "webm";
2320
+ const audio = await new Promise((resolve) => {
2321
+ const reader = new FileReader();
2322
+ reader.onload = () => resolve(String(reader.result).replace(/^data:[^,]*,/, ""));
2323
+ reader.readAsDataURL(blob);
2324
+ });
2325
+ micBtn.disabled = true;
2326
+ dictateShow("working", t("dictate.transcribing"));
2327
+ const heard = await api("/voice/transcribe", { audio, format, duration, language: navigator.language || "" }, 90_000);
2328
+ micBtn.disabled = false;
2329
+ const words = heard && typeof heard.text === "string" ? heard.text.trim() : "";
2330
+ if (!words) {
2331
+ if (heard && heard.code === "sign_in_required") return dictateShow("error", t("dictate.signIn"));
2332
+ return heard && heard.error ? dictateShow("error", t("dictate.failed", { error: heard.error })) : dictateShow("");
2333
+ }
2334
+ const before = input.value;
2335
+ const lead = before && !/\s$/.test(before) ? " " : "";
2336
+ const placed = before + lead + words;
2337
+ input.value = placed;
2338
+ input.dispatchEvent(new Event("input"));
2339
+ input.focus();
2340
+ input.setSelectionRange(placed.length, placed.length);
2341
+ dictateShow("working", t("dictate.tidying"));
2342
+ const tidy = await api("/voice/tidy", { text: words, language: navigator.language || "" }, 20_000);
2343
+ dictateShow("");
2344
+ if (tidy && tidy.tidied === true && typeof tidy.text === "string" && input.value === placed) {
2345
+ input.value = before + lead + tidy.text;
2346
+ input.dispatchEvent(new Event("input"));
2347
+ input.setSelectionRange(input.value.length, input.value.length);
2348
+ }
2349
+ }
2350
+
2351
+ if (micBtn) {
2352
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) micBtn.hidden = true;
2353
+ micBtn.addEventListener("click", () => (dictation ? stopDictation(false) : startDictation()));
2354
+ pop.querySelector(".dp-cancel").addEventListener("click", () => stopDictation(true));
2355
+ pop.querySelector(".dp-done").addEventListener("click", () => stopDictation(false));
2356
+ pop.querySelector(".dp-close").addEventListener("click", () => dictateShow(""));
2357
+ // While listening, the keyboard ends it: Esc throws it away, Enter keeps it.
2358
+ document.addEventListener("keydown", (event) => {
2359
+ if (!dictation) return;
2360
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); stopDictation(true); }
2361
+ else if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); event.stopPropagation(); stopDictation(false); }
2362
+ }, true);
2363
+ }
1725
2364
  `;
1726
2365
  //# sourceMappingURL=composer.js.map