@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
@@ -8,6 +8,9 @@ export const SHELL = String.raw `
8
8
 
9
9
  const projectBtn = document.getElementById("project");
10
10
  let projects = [];
11
+ /** Session directories that are worktrees, each naming the project it came from. */
12
+ let worktrees = [];
13
+ const worktreeAt = (cwd) => worktrees.find((one) => one.path === cwd);
11
14
 
12
15
  /** The last path segment, which is what the directory is called. */
13
16
  function projectName(dir) {
@@ -25,6 +28,7 @@ function paintProject() {
25
28
  // it too, or it goes on advertising the tree the user just left.
26
29
  const cwdEl = document.getElementById("empty-cwd");
27
30
  if (cwdEl && active) cwdEl.textContent = active.name;
31
+ if (active) void paintStarters(active.path);
28
32
  // A directory added while the chip was open belongs on its list.
29
33
  refreshWorkspace();
30
34
  }
@@ -50,6 +54,7 @@ function repaintPanel() {
50
54
  async function loadProjects() {
51
55
  const body = await getJson("/projects");
52
56
  projects = body.projects || [];
57
+ worktrees = body.worktrees || [];
53
58
  projectOrder = body.order || [];
54
59
  paintProject();
55
60
  // A project's pin and its place decide how the session list is grouped, so
@@ -294,6 +299,12 @@ const IS_WIN = /win/i.test(navigator.platform || navigator.userAgent || "");
294
299
  /** The rows the current query left on screen, in the order they are drawn. */
295
300
  let paletteRows = [];
296
301
  let paletteCursor = 0;
302
+ /**
303
+ * Whether the palette was opened from the sidebar's "Search sessions": then it
304
+ * lists sessions and nothing else, and every one of them rather than the
305
+ * recent eight — the question asked was "where is that conversation".
306
+ */
307
+ let paletteSessionsOnly = false;
297
308
 
298
309
  /** A nav item's own icon, so the palette names things the way the sidebar does. */
299
310
  function navIcon(id) {
@@ -315,6 +326,7 @@ const enLabel = (key) => (STRINGS.en && STRINGS.en[key]) || "";
315
326
  function paletteItems() {
316
327
  const items = [];
317
328
  const query = paletteField.value.trim();
329
+ if (paletteSessionsOnly) return sessionItems(sessionRows);
318
330
  const actions = t("palette.actions");
319
331
  const here = (projects.find((project) => project.current) || {}).path || currentCwd;
320
332
 
@@ -342,6 +354,7 @@ function paletteItems() {
342
354
  for (const [id, key] of [
343
355
  ["nav-artifacts", "nav.artifacts"],
344
356
  ["nav-sessions", "nav.sessions"],
357
+ ["nav-usage", "nav.usage"],
345
358
  ["nav-models", "nav.models"],
346
359
  ["nav-routines", "nav.routines"],
347
360
  ["nav-settings", "nav.settings"],
@@ -359,21 +372,7 @@ function paletteItems() {
359
372
  // Unfiltered, the list is a preview of the most recent work and the actions
360
373
  // above it still have to be visible. Once there is a query, every session in
361
374
  // 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
- }
375
+ items.push(...sessionItems(query ? sessionRows : sessionRows.slice(0, 8)));
377
376
 
378
377
  const named = t("palette.projects");
379
378
  for (const project of query ? projects : projects.slice(0, 5)) {
@@ -394,6 +393,23 @@ function paletteItems() {
394
393
  return items;
395
394
  }
396
395
 
396
+ /** Palette rows for sessions, each reopening its own transcript. */
397
+ function sessionItems(rows) {
398
+ const group = t("palette.sessions");
399
+ return rows.map((session) => ({
400
+ kind: "session",
401
+ group,
402
+ icon: () => svgIcon("chat"),
403
+ label: session.title || t("session.untitled"),
404
+ sub: [projectName(session.cwd || ""), ago(session.updatedAt)].filter(Boolean).join(" · "),
405
+ keywords: session.cwd || "",
406
+ run: () => {
407
+ if (session.current) return void setNav(null);
408
+ void openSession(session.id, session.cwd);
409
+ },
410
+ }));
411
+ }
412
+
397
413
  /** Same word-by-word match the pickers use, over the row's hidden aliases too. */
398
414
  const paletteMatch = (item, query) =>
399
415
  matchesQuery(
@@ -454,12 +470,14 @@ function movePalette(step) {
454
470
  if (active) active.scrollIntoView({ block: "nearest" });
455
471
  }
456
472
 
457
- function openPalette() {
473
+ function openPalette(sessionsOnly = false) {
458
474
  // One modal at a time. A permission request is a question the agent is
459
475
  // blocked on; a palette over it would answer neither.
460
476
  if (document.querySelector("dialog[open]")) return;
461
477
  closeAttachMenu();
462
478
  closeRowMenu();
479
+ paletteSessionsOnly = sessionsOnly === true;
480
+ paletteField.placeholder = t(paletteSessionsOnly ? "palette.sessionsPlaceholder" : "palette.placeholder");
463
481
  paletteField.value = "";
464
482
  paletteCursor = 0;
465
483
  paintPalette();
@@ -471,7 +489,7 @@ function openPalette() {
471
489
  // The desktop shell reaches the palette through this: a menu accelerator is
472
490
  // handled before the page ever sees the keystroke, so the menu item calls the
473
491
  // same function the key would have.
474
- window.openCommandPalette = openPalette;
492
+ window.openCommandPalette = () => openPalette();
475
493
 
476
494
  paletteField.addEventListener("input", () => {
477
495
  paletteCursor = 0;
@@ -520,7 +538,8 @@ document.addEventListener("keydown", (e) => {
520
538
  openPalette();
521
539
  });
522
540
 
523
- document.getElementById("nav-search").addEventListener("click", openPalette);
541
+ document.getElementById("nav-search").addEventListener("click", () => openPalette());
542
+ document.getElementById("side-search").addEventListener("click", () => openPalette(true));
524
543
  // The chord as this machine actually spells it, in the one place a mouse user
525
544
  // will see it.
526
545
  document.getElementById("palette-hint").textContent = IS_MAC ? "⌘K" : "Ctrl K";
@@ -586,39 +605,105 @@ rail.addEventListener("focusout", hideRailTip);
586
605
  rail.addEventListener("click", hideRailTip);
587
606
 
588
607
  /**
589
- * What the empty state offers to do, grouped by the kind of work it is.
608
+ * What the empty state offers to do.
590
609
  *
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.
610
+ * The server decides which kind: a project with no memory gets a rotating pool
611
+ * of everyday offers, and one the agent has notes on gets three written from
612
+ * those notes (see ../../starters.ts). Each chip's label is what fits on it,
613
+ * and the sentence it actually sends rides along in data-p.
597
614
  */
598
- const STARTERS = [
599
- ["explain", "bugs", "tests", "review", "faster"],
600
- ["plan", "docs", "digest", "automate", "backlog"],
601
- ["landing", "launch", "pitch", "position", "changelog"],
602
- ];
615
+ const FIXED_STARTERS = ["trip", "email", "research", "savings", "japanTrip"];
616
+ const VISIBLE_STARTERS = 3;
617
+ const STARTER_ROTATION_MS = 6000;
618
+ /** Which project the chips on screen were drawn for, and the newest ask. */
619
+ let startersFor = "";
620
+ let startersAsk = 0;
621
+ let starterOffset = 0;
622
+ let starterTimer;
623
+
624
+ function starterChip(label, prompt) {
625
+ const button = el("button", "", label);
626
+ button.type = "button";
627
+ button.dataset.p = prompt;
628
+ // The label is a chip; the title is the sentence it will actually send.
629
+ button.title = prompt;
630
+ return button;
631
+ }
632
+
633
+ function animateStarters(box) {
634
+ box.classList.remove("fresh");
635
+ void box.offsetWidth;
636
+ box.classList.add("fresh");
637
+ }
638
+
639
+ function stopStarterRotation() {
640
+ clearInterval(starterTimer);
641
+ starterTimer = undefined;
642
+ }
643
+
644
+ function drawFixedStarters(box, animate = false) {
645
+ const ids = Array.from({ length: VISIBLE_STARTERS }, (_, index) =>
646
+ FIXED_STARTERS[(starterOffset + index) % FIXED_STARTERS.length]);
647
+ box.replaceChildren(...ids.map((id) => starterChip(t("starter." + id), t("starter." + id + ".prompt"))));
648
+ if (animate) animateStarters(box);
649
+ }
650
+
651
+ function startStarterRotation(box) {
652
+ stopStarterRotation();
653
+ if (FIXED_STARTERS.length <= VISIBLE_STARTERS) return;
654
+ starterTimer = setInterval(() => {
655
+ // Do not move a choice out from under a mouse pointer or keyboard focus.
656
+ if (!box.offsetParent || box.matches(":hover") || box.matches(":focus-within")) return;
657
+ starterOffset = (starterOffset + 1) % FIXED_STARTERS.length;
658
+ drawFixedStarters(box, true);
659
+ }, STARTER_ROTATION_MS);
660
+ }
603
661
 
604
662
  /**
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.
663
+ * Ask for the project's chips and draw them. Skipped when the project on
664
+ * screen has not changed, so a repaint of the project button does not pay for
665
+ * another round trip. The answer never waits on a model: a project whose set
666
+ * is not written yet gets the fixed three now, and the written set arrives
667
+ * later as a "starters" event (see startersArrived).
608
668
  */
609
- function paintStarters() {
669
+ async function paintStarters(cwd) {
610
670
  const box = document.getElementById("starters");
611
- if (!box) return;
671
+ if (!box || !cwd || cwd === startersFor) return;
672
+ stopStarterRotation();
673
+ startersFor = cwd;
674
+ starterOffset = 0;
675
+ const ask = ++startersAsk;
612
676
  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);
677
+ let body;
678
+ try {
679
+ body = await getJson("/starters?cwd=" + encodeURIComponent(cwd) + "&locale=" + encodeURIComponent(LOCALE));
680
+ } catch {
681
+ body = { kind: "fixed" };
621
682
  }
683
+ // A switch made while this was being written owns the box now.
684
+ if (ask !== startersAsk) return;
685
+ if (body && body.kind === "written" && Array.isArray(body.starters)) {
686
+ box.replaceChildren(...body.starters.map((item) => starterChip(item.label, item.prompt)));
687
+ } else {
688
+ drawFixedStarters(box);
689
+ startStarterRotation(box);
690
+ }
691
+ }
692
+
693
+ /**
694
+ * A set written in the background for a project, pushed when it lands.
695
+ *
696
+ * Swapped in only where it still fits: the same project and language on
697
+ * screen, and the empty state still showing — nobody's chip is replaced while
698
+ * they are reading a conversation, and the next visit reads it from disk.
699
+ */
700
+ function startersArrived(event) {
701
+ const box = document.getElementById("starters");
702
+ if (!box || !event || event.cwd !== startersFor || event.locale !== LOCALE || !Array.isArray(event.starters)) return;
703
+ if (!box.offsetParent) return;
704
+ stopStarterRotation();
705
+ box.replaceChildren(...event.starters.map((item) => starterChip(item.label, item.prompt)));
706
+ animateStarters(box);
622
707
  }
623
708
 
624
709
  /**
@@ -670,9 +755,52 @@ if (IS_DESKTOP && window.oharness.updates) {
670
755
  window.oharness.updates.state().then(paintUpdateHint, () => {});
671
756
  }
672
757
 
758
+ /**
759
+ * A prompt from a link on the website — oharness://new?prompt=… — opened as a
760
+ * new session with the text in the composer, and nothing more.
761
+ *
762
+ * The main process checked and cleaned the text (desktop/deep-link.js), but
763
+ * it came from whatever page the person was on, so this page treats it as
764
+ * someone else's suggestion: it is never sent, no permission is changed and
765
+ * no card is answered. It sits in the box until the person reads it and
766
+ * presses send, or deletes it. A new session rather than the open one, so the
767
+ * suggestion never lands in the middle of a conversation as if it belonged
768
+ * there; and a draft already in the box is only replaced when the person says
769
+ * so, because the box is shared by every tab and the draft may be the only
770
+ * copy of what they were writing.
771
+ *
772
+ * Not before boot has finished: switching sessions while the first one is
773
+ * still being opened leaves the two fighting over the page. Boot takes the
774
+ * one that launched the app at the end; the nudge covers every later click.
775
+ */
776
+ let deepLinkReady = false;
777
+
778
+ async function takeDeepLink() {
779
+ if (!deepLinkReady || !IS_DESKTOP || !window.oharness.deepLink) return;
780
+ let text;
781
+ try {
782
+ text = await window.oharness.deepLink.take();
783
+ } catch {
784
+ return;
785
+ }
786
+ if (typeof text !== "string" || !text) return;
787
+ const draft = input.value.trim();
788
+ if (draft && draft !== text && !confirm(t("deepLink.replaceDraft"))) return;
789
+ await switchTo({ action: "new" });
790
+ input.value = text;
791
+ // Called rather than an input event, which would also open the command
792
+ // menu for a prompt that starts with "/" — one Enter from running it.
793
+ autosize();
794
+ paintMirror();
795
+ input.focus();
796
+ input.setSelectionRange(input.value.length, input.value.length);
797
+ }
798
+ if (IS_DESKTOP && window.oharness.deepLink) {
799
+ window.oharness.deepLink.onArrived(() => void takeDeepLink());
800
+ }
801
+
673
802
  void (async () => {
674
803
  applyStrings();
675
- paintStarters();
676
804
  // The language is here because it is the one thing that decides what to
677
805
  // translate next, and the shell because a desktop window and a browser tab
678
806
  // are different products to the person using them.
@@ -727,18 +855,26 @@ void (async () => {
727
855
  await loadSidePanels();
728
856
  await loadProjects();
729
857
 
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
- }
858
+ // Wired here, after the session has loaded: a chip that submits before the
859
+ // session it would submit to exists is worse than one that waits. Delegated,
860
+ // because the chips are redrawn on every project switch. Absent when the tab
861
+ // opened on a conversation: its first entry took the invitation off the
862
+ // page, and a throw here stopped everything after it, the session list too.
863
+ document.getElementById("starters")?.addEventListener("click", (event) => {
864
+ const button = event.target.closest("#starters button");
865
+ if (!button) return;
866
+ stopStarterRotation();
867
+ input.value = button.dataset.p;
868
+ paintMirror();
869
+ document.getElementById("composer").requestSubmit();
870
+ });
737
871
  await loadSessions();
738
872
  await refreshRoutineCount();
739
873
  await refreshPluginPages();
740
874
  await refreshModelCount();
741
875
  await loadAccount();
742
876
  input.focus();
877
+ deepLinkReady = true;
878
+ await takeDeepLink();
743
879
  })();`;
744
880
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAy2BzB,CAAC"}