@oharness/server 0.1.0 → 0.2.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.
package/dist/page.js CHANGED
@@ -28,8 +28,12 @@ const STYLE = String.raw `
28
28
  --field:#00000006; --field-focus:#0000000d; --track:#0000001f;
29
29
  --kw:#7c3aed; --str:#047857; --com:#94a3b8; --num:#b45309;
30
30
  }
31
+ /* The dark palette, reached two ways: the system asked for it and nothing
32
+ here overrode that, or somebody picked it. Written once and used by both
33
+ selectors — two copies of twenty colours is two places for one of them to
34
+ drift. */
31
35
  @media (prefers-color-scheme:dark){
32
- :root{
36
+ :root:not([data-theme="light"]){
33
37
  color-scheme:dark;
34
38
  --bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
35
39
  --accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
@@ -39,6 +43,15 @@ const STYLE = String.raw `
39
43
  --kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
40
44
  }
41
45
  }
46
+ :root[data-theme="dark"]{
47
+ color-scheme:dark;
48
+ --bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
49
+ --accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
50
+ --add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
51
+ --hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
52
+ --field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
53
+ --kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
54
+ }
42
55
  *{box-sizing:border-box}
43
56
  /* The hidden attribute works through the browser's own display:none, which is
44
57
  a bare element rule any class with a display beats — so .hidden = true left
@@ -184,7 +197,8 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
184
197
  position:fixed;z-index:60;min-width:172px;max-width:228px;padding:5px;border-radius:10px;
185
198
  background:var(--bg);box-shadow:0 1px 2px #0000000f,0 8px 28px #00000024;
186
199
  }
187
- @media (prefers-color-scheme:dark){.row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
200
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
201
+ :root[data-theme="dark"] .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
188
202
  /*
189
203
  * Destructive is carried by the label's colour, not by a filled red bar. A
190
204
  * solid block reads as an alert that has already happened rather than as a
@@ -196,6 +210,35 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
196
210
  .row-menu .menu-note{
197
211
  padding:4px 8px 8px;font-size:11.5px;line-height:1.45;color:var(--dim);
198
212
  }
213
+ /* The account's own two facts, on a surface of their own. A fill separates
214
+ them from the things to click without drawing a line to do it — the menu
215
+ already had two rules in it and a third would have made a list of five
216
+ entries look like three lists. */
217
+ .row-menu .menu-head{
218
+ padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
219
+ }
220
+ .menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
221
+ .menu-head-sub{margin-top:2px;font-size:12px;color:var(--dim)}
222
+ /* One entry carrying the accent, for the one that is an offer rather than a
223
+ destination. var(--field) was too close to the menu's own surface to read as
224
+ anything in the light theme — a highlight nobody can see is worse than none,
225
+ because it costs the layout and returns nothing. */
226
+ .menu-item.accent{background:var(--accent);color:var(--bg)}
227
+ .menu-item.accent:hover{background:var(--accent);opacity:.88}
228
+ /* The theme row: a label and three targets, laid out like a settings line
229
+ rather than a command. */
230
+ .menu-theme{
231
+ display:flex;align-items:center;justify-content:space-between;gap:8px;
232
+ padding:4px 8px;min-height:32px;
233
+ }
234
+ .theme-group{display:flex;gap:2px;padding:2px;border-radius:8px;background:var(--field)}
235
+ .theme-btn{
236
+ display:grid;place-items:center;width:26px;height:22px;border:0;border-radius:6px;
237
+ background:transparent;color:var(--dim);cursor:pointer;padding:0;
238
+ }
239
+ .theme-btn:hover{color:var(--fg)}
240
+ .theme-btn[aria-pressed="true"]{background:var(--bg);color:var(--fg)}
241
+ .theme-btn .ico-sm{width:14px;height:14px}
199
242
  /* The letter that does the same thing without the mouse. Dim, right-aligned,
200
243
  and only ever one character — a menu whose right edge is a column of chords
201
244
  is a keyboard reference, not a menu. */
@@ -284,7 +327,8 @@ main{flex:1;display:flex;flex-direction:column;min-width:0}
284
327
  .menu-footer{padding:5px 3px 3px;box-shadow:0 -1px 0 var(--line)}
285
328
  .menu-item.cursor{background:var(--hover)}
286
329
  .menu-empty{padding:10px 8px;font-size:12.5px;color:var(--dim)}
287
- @media (prefers-color-scheme:dark){.menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
330
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
331
+ :root[data-theme="dark"] .menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
288
332
  .menu-item{
289
333
  display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:7px 8px;border:0;
290
334
  border-radius:7px;background:transparent;color:var(--fg);font:inherit;font-size:13px;
@@ -601,7 +645,40 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
601
645
  */
602
646
  .menu.pop{position:fixed;bottom:auto;left:auto;min-width:220px}
603
647
  #log{padding:28px 24px 8px}
604
- .turn{position:relative;max-width:720px;margin:0 auto 26px}
648
+ /*
649
+ * One column width, named once.
650
+ *
651
+ * A turn and a connection card are different things that must line up, and
652
+ * before this they lined up by both happening to be laid out inside the pane —
653
+ * which stopped being true the moment one of them got a max-width. On a wide
654
+ * window the card then ran the full 1600px while every message sat in a 720px
655
+ * column, so the one element asking for a decision was the one that did not
656
+ * look like it belonged to the conversation.
657
+ */
658
+ .column{max-width:720px;margin-left:auto;margin-right:auto}
659
+ .turn{position:relative;margin-bottom:26px}
660
+
661
+ /*
662
+ * The connection card: an app's own mark, its name, what is being asked, and
663
+ * the two answers. It is the only place a third party's brand appears in the
664
+ * transcript, so it stays inside the column and keeps the site's own shapes —
665
+ * a fill and a radius, no outline.
666
+ */
667
+ .connection-card{display:flex;flex-direction:column;gap:10px;padding:14px 16px}
668
+ .connection-head{display:flex;align-items:center;gap:10px}
669
+ /*
670
+ * Fixed box, contained image: a vendor logo is whatever aspect ratio the vendor
671
+ * chose, and letting it set its own height made cards jump by ten pixels
672
+ * depending on which app was asking.
673
+ */
674
+ .connection-logo{width:28px;height:28px;border-radius:7px;object-fit:contain;flex:none;background:var(--bg)}
675
+ .connection-name{font-size:14px;font-weight:600}
676
+ .connection-card p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
677
+ /* The answers sit at the end of the row: this card is a question, and the two
678
+ * replies belong where a reader arrives last rather than under the sentence
679
+ * they are answering. */
680
+ .connection-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:2px}
681
+ .connection-card .err{color:var(--fg)}
605
682
  .who{
606
683
  font-size:11.5px;color:var(--dim);margin-bottom:6px;font-weight:500;letter-spacing:.01em;
607
684
  }
@@ -619,7 +696,44 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
619
696
  .user{margin-bottom:18px}
620
697
  .panel{max-width:720px;margin:8px auto 0}
621
698
  .panel h2{font-size:18px;font-weight:600;letter-spacing:-.01em;margin:0 0 14px}
622
- .row-card{background:var(--panel);border-radius:10px;padding:12px 14px;margin:0 0 8px}
699
+ /* Only the gap below. The horizontal margin belongs to whatever is placing the
700
+ * card: setting all four here quietly reset the auto margins that keep a card
701
+ * in the reading column, and the card sat flush left while every message beside
702
+ * it was centred. */
703
+ .row-card{background:var(--panel);border-radius:10px;padding:12px 14px;margin-bottom:8px}
704
+ .memory-field{margin:14px 0}
705
+ .memory-label{display:block;font-size:12.5px;font-weight:500;margin-bottom:2px}
706
+ .memory-field .muted{margin-bottom:6px}
707
+ /* Filled, not outlined — the same shape .art-search uses. A box drawn with a
708
+ * line around it is the one thing this site does not do, and three of them
709
+ * stacked made the editor look like a form bolted onto the page rather than
710
+ * part of the note. The focus ring stays: that is the one border worth having,
711
+ * and it appears only while the field is being used. */
712
+ .memory-panel input,.memory-panel textarea{background:var(--field);color:var(--fg);border:0;border-radius:8px;padding:10px 12px;font:inherit;line-height:1.5;box-sizing:border-box}
713
+ .memory-panel input:focus,.memory-panel textarea:focus{outline:2px solid var(--ring)}
714
+ .memory-panel textarea{resize:vertical;min-height:130px}
715
+ .memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
716
+ .memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
717
+ .memory-panel .memory-search{width:100%}
718
+ /* Built like a settings row: one flex line, the marker drawn rather than left
719
+ * to the browser, so the summary text and everything below it share one left
720
+ * edge. */
721
+ .memory-panel details.memory-note>summary{display:flex;align-items:center;gap:8px;list-style:none;padding:2px 0 6px}
722
+ .memory-panel details.memory-note>summary::-webkit-details-marker{display:none}
723
+ .memory-panel details.memory-note[open]>summary .chev{transform:rotate(90deg)}
724
+ .memory-note-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
725
+ .memory-id{flex:0 0 auto;font-size:11px;color:var(--dim);letter-spacing:.02em;user-select:all}
726
+ /* No card of its own — the note row is already one — and indented to the
727
+ * summary's text rather than the card's edge, which is what .setting-body does
728
+ * for the same shape. The 15px is the marker column: a chevron and the gap
729
+ * after it, the same distance the summary text is pushed. Left at the form's
730
+ * own padding the fields sat nine pixels to the right of the title they
731
+ * belonged to, which is the sort of gap that reads as a mistake without
732
+ * anyone being able to say what is wrong. */
733
+ .memory-editor{margin:0;padding:2px 0 4px 15px;background:transparent}
734
+ /* The form's own background is the page's, which on a card reads as a second
735
+ * card inside the first one — the same doubling the padding was making. */
736
+ .memory-actions{display:flex;gap:8px;margin-top:14px}
623
737
  /*
624
738
  * A settings row: name, value, and everything else behind the triangle.
625
739
  *
@@ -783,16 +897,16 @@ details.provider>summary:hover{background:var(--hover)}
783
897
  * is filled: everything below it is an alternative for someone who cannot use
784
898
  * a browser here. Its own class rather than .primary, which is the composer's
785
899
  * 30x30 icon button and would collapse this into a circle. */
900
+ /* No rule above it. The padding and the button's own hover surface already
901
+ separate the account from the list — a hairline as well is a second answer
902
+ to a question the whitespace had settled, and it is the only line left in a
903
+ sidebar that has none. */
786
904
  #account{
787
- border-top:1px solid var(--line);display:flex;align-items:center;gap:8px;
905
+ display:flex;align-items:center;gap:8px;
788
906
  flex:0 0 auto;padding:8px;
789
907
  }
790
908
  /* Signed out this holds one nav row and nothing else, so the padding that
791
- frames an account goes away and the row sits flush with the ones above it —
792
- and the rule above it goes too. A hairline fences off a footer, which is
793
- what this is once there is an account in it, with a name and an avatar of
794
- its own; signed out there is nothing on the other side of the line, just
795
- the next row in the same list, drawn as if it belonged somewhere else.
909
+ frames an account goes away and the row sits flush with the ones above it.
796
910
  8px across, matching the nav list above, so the icon lands on the same
797
911
  vertical as every other row rather than 4px inside it. */
798
912
  /* Not 4px, which is the gap between two rows in a list. This row is the last
@@ -800,7 +914,7 @@ details.provider>summary:hover{background:var(--hover)}
800
914
  another row, and at 4px the text sat on the frame. 10px below, and a row
801
915
  taller than the nav rows above: a footer is allowed to be the roomiest row
802
916
  in the sidebar, and this one has to hold a click aimed at from the corner. */
803
- #account.out{border-top:0;padding:4px 8px 10px}
917
+ #account.out{padding:4px 8px 10px}
804
918
  #account.out .nav-item{width:100%;height:34px}
805
919
  /* The whole row is the control. It used to be a label with a Sign out button
806
920
  parked beside it, which gave the rarest action in the sidebar the most
@@ -834,7 +948,19 @@ button.signin-site:hover{opacity:.88}
834
948
  button.signin-site:disabled{opacity:.4;cursor:default}
835
949
  .pw-balance{display:flex;align-items:baseline;gap:6px;margin:0}
836
950
  .pw-balance strong{font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
837
- .pw-plans{display:flex;flex-direction:column;gap:8px}
951
+ /* The paywall's own width, and its own padding. Everything here is scoped to
952
+ #paywall: the same .cn-body and .dlg-actions serve the connect dialog, and
953
+ the last time a rule meant for one of them was written without a scope it
954
+ resized three screens nobody had complained about. */
955
+ #paywall{width:min(620px,94vw);max-width:min(620px,94vw)}
956
+ #paywall .cn-body{padding:16px 22px 4px;gap:14px}
957
+ #paywall .dlg-actions{padding:18px 22px 20px}
958
+ #paywall .cn-head{padding:20px 22px 2px}
959
+ /* Two across on a screen with room, one on a phone. Four tiers in a single
960
+ column made a dialog taller than the window it interrupts, which is the one
961
+ shape a modal should not have. */
962
+ .pw-plans{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
963
+ @media (max-width:560px){.pw-plans{grid-template-columns:1fr}}
838
964
  /* Every row carries the colour. This dialog interrupts a turn that could not
839
965
  * be paid for, so each row is the way out of it rather than one recommendation
840
966
  * and a set of alternatives — and an outlined row beside a filled one reads as
@@ -845,14 +971,63 @@ button.signin-site:disabled{opacity:.4;cursor:default}
845
971
  * .primary is the composer's 30x30 icon button, and inheriting its width,
846
972
  * height and display:grid collapsed this row into a square with the label
847
973
  * clipped inside it. */
974
+ /* Quiet cards, one mark.
975
+ *
976
+ * These used to be filled with the accent, every one of them, on the reasoning
977
+ * that each row is the way out of a turn that could not be paid for. That held
978
+ * when they were a short flat list. At four tiers plus a ladder of top-ups it
979
+ * is eight blocks of inverted colour in a small dialog, and the eye has
980
+ * nowhere to rest — the reader is choosing between shades rather than reading
981
+ * prices.
982
+ *
983
+ * The site's pricing page settled this and says why in its own stylesheet: the
984
+ * offers should read as columns of type rather than as a row of frames, and on
985
+ * a screen that is mostly numbers the frames are the loudest thing on it. So
986
+ * the fill goes quiet and the recommended rung keeps the one outline, which is
987
+ * what makes it a mark rather than a shade somebody has to notice.
988
+ */
848
989
  button.pw-plan{
849
- display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;
850
- min-height:36px;padding:8px 12px;border:1px solid transparent;border-radius:8px;
851
- background:var(--accent);color:var(--bg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
852
- }
853
- button.pw-plan:hover{opacity:.88}
990
+ display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
991
+ min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
992
+ background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
993
+ }
994
+ button.pw-plan.featured{border-color:var(--border-strong,var(--fg));background:var(--sel)}
995
+ .pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
996
+ button.pw-plan:hover{background:var(--sel)}
997
+ button.pw-plan.featured:hover{background:var(--hover)}
854
998
  button.pw-plan:disabled{opacity:.4;cursor:default}
855
- .pw-plan-credits{color:var(--bg);opacity:.75;font-variant-numeric:tabular-nums;flex:0 0 auto}
999
+ .pw-plan-name{display:flex;align-items:center;gap:6px;min-width:0;font-weight:500}
1000
+ .pw-plan-under{display:flex;align-items:center;justify-content:space-between;gap:8px}
1001
+ .pw-plan-credits{color:var(--dim);font-variant-numeric:tabular-nums;font-size:11.5px}
1002
+ .pw-plan-under>span:first-child{color:var(--dim);font-variant-numeric:tabular-nums;font-size:11.5px}
1003
+ /* Sits under the price rather than beside the name: the badge up there says
1004
+ which rung to take, this one says what taking it for a year costs. */
1005
+ .pw-save{font-size:11px;font-weight:500;color:var(--fg);flex:0 0 auto;font-variant-numeric:tabular-nums}
1006
+ /* The money, hard against the right edge and in the same tabular figures as
1007
+ the credits, so four rows read as a column of prices rather than four
1008
+ sentences that happen to end in numbers. */
1009
+ .pw-plan-price{color:var(--fg);font-weight:500;font-variant-numeric:tabular-nums;flex:0 0 auto;white-space:nowrap}
1010
+ /* The one inverted thing left, and the same shape the site uses for it. */
1011
+ .pw-tag{
1012
+ font-size:10.5px;font-weight:600;letter-spacing:.02em;padding:1px 7px;border-radius:999px;
1013
+ background:var(--fg);color:var(--bg);flex:0 0 auto;
1014
+ }
1015
+ /* One choice about the four rows beneath, so it is a control rather than four
1016
+ more buttons. */
1017
+ .pw-toggle{display:flex;gap:4px;padding:3px;border-radius:10px;background:var(--field);margin-bottom:2px}
1018
+ .pw-tab{
1019
+ flex:1;height:28px;border:0;border-radius:8px;background:transparent;color:var(--dim);
1020
+ font:inherit;font-size:12.5px;cursor:pointer;
1021
+ }
1022
+ .pw-tab:hover{color:var(--fg)}
1023
+ .pw-tab.on{background:var(--bg);color:var(--fg);font-weight:500}
1024
+ /* A labelled break, not a rule: what follows is a different kind of purchase,
1025
+ and a bare line would read as the list simply continuing. */
1026
+ .pw-sep{
1027
+ display:flex;align-items:center;gap:8px;margin:4px 0 2px;
1028
+ font-size:11.5px;color:var(--dim);
1029
+ }
1030
+ .pw-sep::after{content:"";flex:1;height:1px;background:var(--line)}
856
1031
  .provider-advanced{margin-top:8px}
857
1032
  .provider-advanced>summary{
858
1033
  font-size:12px;color:var(--dim);cursor:pointer;list-style:none;padding:2px 0;
@@ -915,8 +1090,9 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
915
1090
  measured to be dark, flat and mostly transparent — a logo with colour in it,
916
1091
  or one carrying its own background, is left exactly as its owner drew it. */
917
1092
  @media (prefers-color-scheme:dark){
918
- .mk-logo.invert-dark{filter:invert(1)}
1093
+ :root:not([data-theme="light"]) .mk-logo.invert-dark{filter:invert(1)}
919
1094
  }
1095
+ :root[data-theme="dark"] .mk-logo.invert-dark{filter:invert(1)}
920
1096
  /* The fallback, and the reason the icon is allowed to fail: a lettered tile
921
1097
  still tells two adjacent rows apart, which is the whole job. */
922
1098
  .mk-mono{display:grid;place-items:center;font-size:13px;font-weight:600;color:#fff}
@@ -1512,6 +1688,13 @@ const ICON_PATHS = {
1512
1688
  // both menus open upward, and both mark themselves with a caret that reads as
1513
1689
  // "there is a list here" rather than one that promises a direction.
1514
1690
  caret: ["M4.5 6.5 8 10l3.5-3.5"],
1691
+ // The three themes, drawn the same way the site draws them so the two
1692
+ // controls are recognisably the same control.
1693
+ themeSystem: ["M6 13.4h4"],
1694
+ themeLight: ["M8 1.4v1.4M8 13.2v1.4M1.4 8h1.4M13.2 8h1.4M3.4 3.4l1 1M11.6 11.6l1 1M12.6 3.4l-1 1M4.4 11.6l-1 1"],
1695
+ themeDark: ["M13.2 9.6A5.6 5.6 0 0 1 6.4 2.8a5.6 5.6 0 1 0 6.8 6.8Z"],
1696
+ themeSystemRects: [[1.8, 2.6, 12.4, 8.6, 1.6]],
1697
+ themeLightCircles: [[8, 8, 3.1]],
1515
1698
  // A page with its corner turned back, which is the one mark that reads as
1516
1699
  // "file" at thirteen pixels without any lines of text inside it.
1517
1700
  file: [
@@ -1570,6 +1753,15 @@ function svgIcon(name, extra) {
1570
1753
  }
1571
1754
  // The radius is per-circle because the icons it serves disagree: a fork's
1572
1755
  // node is a dot, a settings knob is a ring you look through.
1756
+ for (const [x, y, w, h, rx] of ICON_PATHS[name + "Rects"] || []) {
1757
+ const rect = document.createElementNS(NS, "rect");
1758
+ rect.setAttribute("x", String(x));
1759
+ rect.setAttribute("y", String(y));
1760
+ rect.setAttribute("width", String(w));
1761
+ rect.setAttribute("height", String(h));
1762
+ rect.setAttribute("rx", String(rx ?? 1.6));
1763
+ svg.appendChild(rect);
1764
+ }
1573
1765
  for (const [cx, cy, r] of ICON_PATHS[name + "Circles"] || []) {
1574
1766
  const circle = document.createElementNS(NS, "circle");
1575
1767
  circle.setAttribute("cx", String(cx));
@@ -2121,7 +2313,7 @@ let P = null;
2121
2313
 
2122
2314
  function turn(role) {
2123
2315
  hideEmpty();
2124
- const wrap = el("div", "turn " + role);
2316
+ const wrap = el("div", "turn column " + role);
2125
2317
  wrap.appendChild(el("div", "who", role === "user" ? "you" : "assistant"));
2126
2318
  P.node.appendChild(wrap);
2127
2319
  follow();
@@ -2988,8 +3180,49 @@ function placeRowMenu(button) {
2988
3180
  * reads the menu that is actually open instead of a list that has to be kept
2989
3181
  * in step with it.
2990
3182
  */
3183
+ /**
3184
+ * The three themes, side by side and pressed rather than cycled.
3185
+ *
3186
+ * A cycling entry made the reader click twice to reach dark and told them the
3187
+ * current state only by reading a word. Three targets say what the options are
3188
+ * and which one is on, in one glance and one click — and it is the control the
3189
+ * site already uses in the same menu, so the two are recognisably the same
3190
+ * thing rather than two designs for one preference.
3191
+ *
3192
+ * Not a menuItem: this row is not a command, and closing the menu on click
3193
+ * would hide the change the click was for.
3194
+ */
3195
+ function themeRow() {
3196
+ const row = el("div", "menu-theme");
3197
+ row.appendChild(el("span", "menu-label", t("theme.label")));
3198
+ const group = el("div", "theme-group");
3199
+ group.setAttribute("role", "group");
3200
+ group.setAttribute("aria-label", t("theme.label"));
3201
+ THEMES.forEach((option) => {
3202
+ const button = el("button", "theme-btn");
3203
+ button.type = "button";
3204
+ button.title = t("theme." + option);
3205
+ button.setAttribute("aria-label", t("theme." + option));
3206
+ button.setAttribute("aria-pressed", String(option === theme));
3207
+ button.appendChild(svgIcon("theme" + option[0].toUpperCase() + option.slice(1)));
3208
+ button.addEventListener("click", (e) => {
3209
+ e.stopPropagation();
3210
+ theme = option;
3211
+ applyTheme();
3212
+ // The menu stays open and repaints its own pressed states: the point of
3213
+ // three buttons is that the next one is one click away.
3214
+ [...group.children].forEach((node, i) => {
3215
+ node.setAttribute("aria-pressed", String(THEMES[i] === theme));
3216
+ });
3217
+ });
3218
+ group.appendChild(button);
3219
+ });
3220
+ row.appendChild(group);
3221
+ return row;
3222
+ }
3223
+
2991
3224
  function menuItem(label, opts) {
2992
- const item = el("button", "menu-item" + (opts.danger ? " danger" : ""));
3225
+ const item = el("button", "menu-item" + (opts.danger ? " danger" : "") + (opts.cls ? " " + opts.cls : ""));
2993
3226
  item.type = "button";
2994
3227
  item.appendChild(el("span", "menu-label", label));
2995
3228
  if (opts.key) {
@@ -3229,6 +3462,9 @@ async function loadModels() {
3229
3462
  if (body.error) return void addNotice(body.error, "err");
3230
3463
  const models = body.models || [];
3231
3464
  providers = body.providers || [];
3465
+ // Kept for the connect dialog, which needs somewhere to send a person for a
3466
+ // key and is opened from places that have no response of their own to read.
3467
+ modelsSite = body.site || modelsSite;
3232
3468
 
3233
3469
  // Grouped by vendor. Three things decide the order, in this order:
3234
3470
  //
@@ -3289,6 +3525,90 @@ async function loadModels() {
3289
3525
  modelPicker.set(modelOptions, current ? current.id : null);
3290
3526
  }
3291
3527
 
3528
+ const connectionCards = new Map();
3529
+
3530
+ function connectionCard(card) {
3531
+ connectionCards.set(card.id, card);
3532
+ const target = pane(card.session);
3533
+ let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
3534
+ if (!box) {
3535
+ box = el("section", "row-card column connection-card");
3536
+ box.dataset.connectionId = card.id;
3537
+ box.setAttribute("aria-live", "polite");
3538
+ target.node.appendChild(box);
3539
+ }
3540
+ box.replaceChildren();
3541
+ const head = el("div", "connection-head");
3542
+ /*
3543
+ * The app's own mark, when Cloud sent one and it is https. A logo is the
3544
+ * fastest way to tell Gmail from a page pretending to be Gmail, which is
3545
+ * exactly why the URL is not taken on trust: it arrives from a vendor
3546
+ * catalogue, and an http or javascript: src would be a hole opened for
3547
+ * decoration. A card without one is a card with a blank square, never a
3548
+ * broken image.
3549
+ */
3550
+ if (card.logo) {
3551
+ let safe;
3552
+ try { const parsed = new URL(card.logo); if (parsed.protocol === "https:") safe = parsed.href; } catch { safe = undefined; }
3553
+ if (safe) {
3554
+ const logo = el("img", "connection-logo");
3555
+ logo.src = safe; logo.alt = ""; logo.loading = "lazy"; logo.referrerPolicy = "no-referrer";
3556
+ logo.onerror = () => logo.remove();
3557
+ head.appendChild(logo);
3558
+ }
3559
+ }
3560
+ head.appendChild(el("span", "connection-name", card.label));
3561
+ box.appendChild(head);
3562
+ /*
3563
+ * The key when there is one, the text when there is not. The engine writes
3564
+ * both because the same card also reaches surfaces with no translation
3565
+ * table; here, where there is one, a card that spoke a different language
3566
+ * from the window around it was the bug.
3567
+ */
3568
+ box.appendChild(el("p", "", card.messageKey ? t(card.messageKey) : card.message));
3569
+ if (card.state !== "required" && card.state !== "authorizing") return;
3570
+ const actions = el("div", "connection-actions");
3571
+ const start = el("button", "ghost", card.state === "authorizing" ? t("connect.open") : t("connect.action", { app: card.label }));
3572
+ const cancel = el("button", "ghost", t("connect.cancel"));
3573
+ actions.appendChild(start);
3574
+ actions.appendChild(cancel);
3575
+ box.appendChild(actions);
3576
+ start.onclick = async () => {
3577
+ start.disabled = true;
3578
+ // Reserve a window in the click gesture; credentials never enter its URL.
3579
+ const popup = window.open("about:blank", "_blank");
3580
+ if (popup) popup.opener = null;
3581
+ try {
3582
+ const result = await api("/cloud-connection", { id: card.id, action: "start" }, 60000);
3583
+ if (result.error) throw new Error(result.error);
3584
+ if (result.redirectUrl) {
3585
+ const url = new URL(result.redirectUrl);
3586
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("无效授权链接");
3587
+ if (popup && !popup.closed) popup.location.replace(url.href);
3588
+ else {
3589
+ const currentBox = target.node.querySelector('[data-connection-id="' + card.id + '"]');
3590
+ if (currentBox && connectionCards.get(card.id)?.state === "authorizing") {
3591
+ const link = el("a", "", t("connect.openLink"));
3592
+ link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer";
3593
+ currentBox.appendChild(link);
3594
+ }
3595
+ }
3596
+ } else if (popup) popup.close();
3597
+ } catch (error) {
3598
+ if (popup) popup.close();
3599
+ box.appendChild(el("p", "err", error.message || t("connect.failed")));
3600
+ } finally { start.disabled = false; }
3601
+ };
3602
+ cancel.onclick = async () => {
3603
+ cancel.disabled = true;
3604
+ try {
3605
+ const result = await api("/cloud-connection", { id: card.id, action: "cancel" });
3606
+ if (result.error) throw new Error(result.error);
3607
+ } catch { box.appendChild(el("p", "err", t("connect.cancelFailed"))); }
3608
+ finally { cancel.disabled = false; }
3609
+ };
3610
+ }
3611
+
3292
3612
  function renderHistory(entries, into) {
3293
3613
  P = into || P;
3294
3614
  const hasHistory = (entries || []).length > 0;
@@ -3337,6 +3657,9 @@ function renderHistory(entries, into) {
3337
3657
  }
3338
3658
  if (P.turn) turnActions(P.turn, entry.keepMessages);
3339
3659
  }
3660
+ for (const card of connectionCards.values()) {
3661
+ if (pane(card.session) === P) connectionCard(card);
3662
+ }
3340
3663
  atBottom = true;
3341
3664
  log.scrollTop = log.scrollHeight;
3342
3665
  }
@@ -3385,6 +3708,9 @@ events.onmessage = (message) => {
3385
3708
  }
3386
3709
 
3387
3710
  switch (event.type) {
3711
+ case "connection_card":
3712
+ connectionCard(event.card);
3713
+ break;
3388
3714
  case "prompt": {
3389
3715
  // The previews this tab made a moment ago, so a message just sent looks
3390
3716
  // like the same message will after a reload — the transcript has not
@@ -3617,9 +3943,17 @@ let askState = null;
3617
3943
  */
3618
3944
  function askRuleKey(request) {
3619
3945
  const name = request.toolName || "";
3620
- const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
3946
+ // The spelling candidates() writes a rule in: capitalized, unless the name is
3947
+ // already snake_case and would only be disfigured by it.
3948
+ const capitalized = name.includes("_") ? name : name.charAt(0).toUpperCase() + name.slice(1);
3621
3949
  const input = request.input;
3622
3950
  if (!input || typeof input !== "object") return capitalized;
3951
+ // A platform app call is named by its app, and by the app's own tool when it
3952
+ // is executing one — see principalArgument() on the server.
3953
+ if (typeof input.app === "string") {
3954
+ const app = typeof input.tool === "string" ? input.app + ":" + input.tool : input.app;
3955
+ return capitalized + "(" + app + ")";
3956
+ }
3623
3957
  const field =
3624
3958
  name === "bash" ? "command"
3625
3959
  : "path" in input ? "path"
@@ -5438,6 +5772,7 @@ async function showModels() {
5438
5772
  if (body.error) return void panelBody.appendChild(panelError(body.error));
5439
5773
  const models = body.models || [];
5440
5774
  const list = body.providers || [];
5775
+ modelsSite = body.site || modelsSite;
5441
5776
 
5442
5777
  const panel = el("div", "panel");
5443
5778
  const head = el("div", "row-head");
@@ -5587,8 +5922,29 @@ async function showModels() {
5587
5922
  // Who is signed in, when anyone is. A plan whose credential is an account
5588
5923
  // has one question a "connected" tag cannot answer — whose — and it is the
5589
5924
  // question a stale login gets wrong.
5925
+ // Which key, when a key is what this machine is using. Shown for the same
5926
+ // reason the account email is: "connected" does not say by what, and the
5927
+ // one thing somebody needs in order to revoke it is the name they gave it.
5928
+ if (provider.keyLabel) {
5929
+ cardBody.appendChild(
5930
+ el("div", "muted small", t("models.usingKey", { label: provider.keyLabel })),
5931
+ );
5932
+ }
5590
5933
  if (provider.account) {
5591
- cardBody.appendChild(el("div", "muted small", t("models.signedInAs", { email: provider.account })));
5934
+ // Whose, and whether it still counts. The email comes from the stored
5935
+ // credential and survives it going stale, so claiming "signed in" on the
5936
+ // strength of it told people they had a session when they had the
5937
+ // remains of one — which is exactly how a dead sign-in button came to
5938
+ // look like a bug in the button rather than in the sentence above it.
5939
+ cardBody.appendChild(
5940
+ el(
5941
+ "div",
5942
+ "muted small",
5943
+ provider.configured
5944
+ ? t("models.signedInAs", { email: provider.account })
5945
+ : t("models.wasSignedInAs", { email: provider.account }),
5946
+ ),
5947
+ );
5592
5948
  }
5593
5949
 
5594
5950
  // An account-backed plan holds no key the subscriber could paste: the
@@ -5601,7 +5957,10 @@ async function showModels() {
5601
5957
  // emailed code for a machine with no browser — and having a second copy of
5602
5958
  // them on this card meant anything added to the sign-in had to be added
5603
5959
  // twice, with the copy that was missed going quietly stale.
5604
- if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
5960
+ // Nothing to sign in to. The card says what is missing and how to supply
5961
+ // it, in the same terms the CLI uses, rather than showing a button whose
5962
+ // only possible outcome is a dialog that also cannot work.
5963
+ const signInButton = () => {
5605
5964
  const go = el("button", "signin-site", t("models.signIn"));
5606
5965
  go.type = "button";
5607
5966
  go.addEventListener("click", (e) => {
@@ -5609,7 +5968,23 @@ async function showModels() {
5609
5968
  e.stopPropagation();
5610
5969
  openConnect(provider.id);
5611
5970
  });
5612
- cardBody.appendChild(go);
5971
+ return go;
5972
+ };
5973
+
5974
+ if (provider.readiness === "no-account-system") {
5975
+ cardBody.appendChild(el("div", "muted small", t("models.noAccounts")));
5976
+ cardBody.appendChild(el("div", "muted small", t("models.noAccountsHow")));
5977
+ } else if (provider.readiness === "cannot-renew") {
5978
+ // The engine's own message about why the renewal failed. Shown rather
5979
+ // than summarised: this is somebody's own machine, and "could not be
5980
+ // renewed" without the reason is a sentence they can do nothing with.
5981
+ // The button stays, because signing in again is a real answer here —
5982
+ // unlike the case above, where there is nothing to sign in to.
5983
+ cardBody.appendChild(el("div", "muted small", t("models.cannotRenew")));
5984
+ if (provider.readinessDetail) cardBody.appendChild(el("div", "muted small", provider.readinessDetail));
5985
+ cardBody.appendChild(signInButton());
5986
+ } else if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
5987
+ cardBody.appendChild(signInButton());
5613
5988
  }
5614
5989
 
5615
5990
  // The key field, for every vendor that takes one — the account plans
@@ -5811,6 +6186,8 @@ function openReconnect(providerId) {
5811
6186
  * paste and works everywhere, including where no mail is reachable. The
5812
6187
  * sign-in is one line below it for the accounts that prefer it.
5813
6188
  */
6189
+ let modelsSite = "";
6190
+
5814
6191
  function openConnect(providerId, thenModel) {
5815
6192
  const provider = providers.find((p) => p.id === providerId);
5816
6193
  if (!provider) return;
@@ -5823,7 +6200,44 @@ function openConnect(providerId, thenModel) {
5823
6200
  // would be an advertisement in the way of the field they came to fill in.
5824
6201
  document.getElementById("cn-aside").hidden = !provider.signIn;
5825
6202
  document.getElementById("cn-title").textContent = provider.label || provider.id;
5826
- document.getElementById("cn-desc").textContent = provider.description || "";
6203
+ // What this dialog is answering. A credential that ran out and one that was
6204
+ // never created both arrive here, and telling someone to "sign in" when what
6205
+ // happened is "your session ended" leaves them wondering whether the first
6206
+ // one ever worked.
6207
+ // The provider's own description advertises signing in. Left in place on a
6208
+ // build that cannot sign in, it contradicts the sentence directly beneath it
6209
+ // — which is what made the dialog read as an error rather than as one of two
6210
+ // doors being shut.
6211
+ document.getElementById("cn-desc").textContent =
6212
+ provider.readiness === "no-account-system"
6213
+ ? t("models.keyOnlyDesc")
6214
+ : provider.readiness === "expired"
6215
+ ? t("models.expired")
6216
+ : provider.readiness === "cannot-renew"
6217
+ ? t("models.cannotRenew")
6218
+ : provider.description || "";
6219
+
6220
+ // Nothing to sign in *to*, which is not the same as nothing to do. The
6221
+ // official plan takes two credentials: an account, which needs a project
6222
+ // this build does not know, and an oh- key, which is a bearer token for the
6223
+ // gateway and needs no project at all. So the dialog explains why the first
6224
+ // is unavailable and offers the second, rather than the Cancel button it
6225
+ // offered at first — which told someone with a perfectly good key that they
6226
+ // could not use this provider.
6227
+ //
6228
+ // An earlier version wrote a notice instead of opening anything. addNotice
6229
+ // puts its text in the transcript when no panel is up, so the explanation
6230
+ // arrived labelled as the assistant, reading as something the model said.
6231
+ const noAccounts = provider.readiness === "no-account-system";
6232
+ if (noAccounts) {
6233
+ // The left column sells having an account. Wrong thing to show beside a
6234
+ // sentence saying accounts do not exist in this build.
6235
+ document.getElementById("cn-aside").hidden = true;
6236
+ }
6237
+ // Undone on every open rather than only where it is set: this dialog is one
6238
+ // node reused by every provider, and a control hidden for one that stayed
6239
+ // hidden for the next is the classic way a shared dialog goes wrong.
6240
+ save.hidden = false;
5827
6241
 
5828
6242
  // Which of the two ways in this dialog is for. Fixed once, not switchable:
5829
6243
  // a provider whose credential is a key gets the key field, and one with an
@@ -5832,7 +6246,7 @@ function openConnect(providerId, thenModel) {
5832
6246
  // window whose whole job is "sign in" — two unrelated things wearing one
5833
6247
  // title. Pasting an oh- key is a settings action and lives on the
5834
6248
  // provider's card, where the other vendors' key fields already are.
5835
- const mode = provider.signIn ? "account" : "key";
6249
+ const mode = provider.signIn && !noAccounts ? "account" : "key";
5836
6250
  let flow = null;
5837
6251
 
5838
6252
  const field = document.createElement("input");
@@ -5897,7 +6311,12 @@ function openConnect(providerId, thenModel) {
5897
6311
  * as though the code is the way in and the button above it is an aside.
5898
6312
  */
5899
6313
  const focusPrimary = () => {
5900
- if (mode === "account" && !flow && siteButton) siteButton.focus();
6314
+ // Whatever the way in is. In account mode that is the website button; with
6315
+ // no account system it is the one that fetches a key, which is the same
6316
+ // shape of thing and deserves the same treatment. The field is where the
6317
+ // cursor goes only when pasting is what is left.
6318
+ const opener = mode === "account" && !flow ? siteButton : noAccounts ? keyButton : undefined;
6319
+ if (opener) opener.focus();
5901
6320
  else field.focus();
5902
6321
  };
5903
6322
 
@@ -5908,6 +6327,13 @@ function openConnect(providerId, thenModel) {
5908
6327
  // Two lines, and the variable name kept whole: as one sentence it wrapped
5909
6328
  // mid-identifier, which reads as a different variable than the one to set.
5910
6329
  hint.replaceChildren(el("div", null, t("models.keyStored")));
6330
+ // Why the other half of this dialog is missing, and how to get it back.
6331
+ // Behind the mark rather than in the body: it is an answer to a question
6332
+ // most readers do not have, and the ones who do will look for it.
6333
+ if (noAccounts) {
6334
+ hint.appendChild(el("div", null, t("models.noAccounts")));
6335
+ hint.appendChild(el("div", null, t("models.noAccountsHow")));
6336
+ }
5911
6337
  if ((provider.envVars || []).length) {
5912
6338
  const line = el("div");
5913
6339
  line.appendChild(document.createTextNode(t("models.keyEnv") + " "));
@@ -5943,7 +6369,11 @@ function openConnect(providerId, thenModel) {
5943
6369
  // the reader choose by position, and position would put the emphasis on
5944
6370
  // the wrong one here — in account mode the fallback is what sits in the
5945
6371
  // actions row, and the way in is the button above the field.
5946
- save.classList.toggle("approve", mode === "key");
6372
+ // The same reasoning as account mode, now that key mode can also have a
6373
+ // button above the field: whichever control is the way in carries the
6374
+ // colour, and the actions row holds the fallback. Two filled buttons make
6375
+ // the reader choose by position, and position would pick the wrong one.
6376
+ save.classList.toggle("approve", mode === "key" && !noAccounts);
5947
6377
  field.value = "";
5948
6378
  focusPrimary();
5949
6379
  };
@@ -6028,7 +6458,79 @@ function openConnect(providerId, thenModel) {
6028
6458
  document.getElementById("cn-cancel").onclick = () => dialog.close();
6029
6459
  document.getElementById("cn-x").onclick = () => dialog.close();
6030
6460
 
6031
- body.replaceChildren(site, orCode, field, hint);
6461
+ // Where the key comes from. The text above says a key is the way in and then
6462
+ // said nothing about getting one, which is half an instruction. siteUrl has a
6463
+ // default, so this resolves even on a build that knows no account project.
6464
+ // A button, not a link. The link left the person with a key on their
6465
+ // clipboard and a field to find again; the browser round trip ends with the
6466
+ // key already stored, because the site hands it back over the same loopback
6467
+ // the sign-in uses. It is also where keys are listed and revoked, so
6468
+ // "create one" and "revoke the one this machine was using" are the same
6469
+ // trip.
6470
+ //
6471
+ // Degrades to the link when this window was not reached over loopback: the
6472
+ // site will only redirect a credential to 127.0.0.1, so from a LAN address
6473
+ // the round trip cannot complete and copy-and-paste is the honest offer.
6474
+ let keyButton = null;
6475
+ const keyLink = () => {
6476
+ const href = (modelsSite || "https://oharness.ai") + "/" + LOCALE + "/dashboard/keys";
6477
+ const go = el("button", "signin-site", t("models.getOhKey"));
6478
+ go.type = "button";
6479
+ go.onclick = (e) => {
6480
+ e.preventDefault();
6481
+ const inDialog = (reason) => {
6482
+ hint.textContent = reason;
6483
+ hint.hidden = false;
6484
+ field.focus();
6485
+ };
6486
+ startWebsiteSignIn(
6487
+ provider.id,
6488
+ () => {
6489
+ if (dialog.open) void done("connected " + provider.id);
6490
+ },
6491
+ go,
6492
+ (reason) => {
6493
+ // Reached over a LAN address, so the site will not redirect a
6494
+ // credential back here. The tab still opens — copying a key across
6495
+ // is the way in that remains — and the field below is where it goes.
6496
+ window.open(href, "_blank", "noopener");
6497
+ inDialog(reason);
6498
+ },
6499
+ "key",
6500
+ inDialog,
6501
+ );
6502
+ };
6503
+ keyButton = go;
6504
+ // The same wrapper the website sign-in uses. signin-site carries flex:1,
6505
+ // which is right in a row and wrong in the column this body is — there it
6506
+ // resolves to flex-basis:0 on the height and squashes the button to 25px
6507
+ // above a 38px field. Putting it in the row it was written for fixes that
6508
+ // without changing a rule four other places depend on.
6509
+ const row = el("div", "keyrow");
6510
+ row.appendChild(go);
6511
+ return row;
6512
+ };
6513
+
6514
+ body.replaceChildren(
6515
+ // Why the account half of this dialog is missing, above the half that
6516
+ // still works. Absent for every provider that has nothing to explain.
6517
+ // One line and a button. The recipe for turning sign-in back on used to sit
6518
+ // here as a five-line paragraph, in front of everybody, for the few people
6519
+ // building their own fork — and a dialog whose first screenful is
6520
+ // configuration for somebody else is a dialog nobody reads. It moved
6521
+ // behind the mark, which is what the mark is for.
6522
+ // The button, and the sentence that makes the field beneath it an
6523
+ // alternative rather than a step. "Signing in is not available" moved
6524
+ // behind the mark with the rest of it: the description already says this
6525
+ // dialog connects with a key, so the line answered a question only
6526
+ // somebody expecting a sign-in would ask — and they are exactly who opens
6527
+ // the mark.
6528
+ ...(noAccounts ? [keyLink(), el("div", "cn-note", t("models.orPasteKey"))] : []),
6529
+ site,
6530
+ orCode,
6531
+ field,
6532
+ hint,
6533
+ );
6032
6534
  // Re-attached on every open: the actions row is markup, so a link left from
6033
6535
  // the last vendor would otherwise point at their key page. The link is built
6034
6536
  // fresh each time, so the previous one has to be taken out by hand — testing
@@ -6164,16 +6666,53 @@ async function loadAccount() {
6164
6666
  // for separately so a gateway having a bad day cannot keep the row from
6165
6667
  // saying who is signed in, which is the thing it is actually for.
6166
6668
  let balance = null;
6669
+ // The plan, from the same answer the balance comes out of. It was already in
6670
+ // the response and thrown away, and it is the other half of the sentence:
6671
+ // "0 credits" reads as a problem to someone on a plan and as a fact of life
6672
+ // to someone who never subscribed, and the two want different next steps.
6673
+ let plan = null;
6674
+ // Whether there is a shop at all. A self-hosted gateway sells nothing, and
6675
+ // an entry that opens a paywall with no plans in it is worse than no entry.
6676
+ let sellsCredits = false;
6167
6677
  void getJson("/billing").then((state) => {
6168
- if (!state || !state.signedIn || state.unavailable || state.balanceMicro === undefined) return;
6169
- balance = t("paywall.balance", { credits: creditsOf(state.balanceMicro) });
6678
+ if (!state || !state.signedIn || state.unavailable) return;
6679
+ sellsCredits = true;
6680
+ if (state.balanceMicro !== undefined) {
6681
+ balance = t("paywall.balance", { credits: creditsOf(state.balanceMicro) });
6682
+ }
6683
+ const sub = state.subscription;
6684
+ plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
6170
6685
  });
6171
6686
 
6172
6687
  row.addEventListener("click", (e) => {
6173
6688
  e.stopPropagation();
6174
6689
  if (menuRow === row) return closeRowMenu();
6175
6690
  const items = [];
6176
- if (balance) items.push(el("div", "menu-note", balance));
6691
+ // The two facts, in one block with a surface under them. They were two
6692
+ // dim lines the size of a footnote, above the things to click — which is
6693
+ // backwards: the balance is why somebody opened this menu, and the plan
6694
+ // is what decides which of the entries below is the one they want.
6695
+ if (balance || plan) {
6696
+ const head = el("div", "menu-head");
6697
+ if (balance) head.appendChild(el("div", "menu-head-main", balance));
6698
+ if (plan) head.appendChild(el("div", "menu-head-sub", plan));
6699
+ items.push(head);
6700
+ }
6701
+ // Buying, before the dashboard. Somebody who opened this menu after seeing
6702
+ // "0 credits" two lines up wants the shop, and the dashboard is a longer
6703
+ // way to the same place. It opens the paywall this app already has rather
6704
+ // than a browser tab: the balance refreshes itself there, and the turn
6705
+ // that could not be paid for is one click from being sent again.
6706
+ if (sellsCredits) {
6707
+ items.push(
6708
+ menuItem(t("account.getCredits"), {
6709
+ run: () => {
6710
+ closeRowMenu();
6711
+ openPaywall(true);
6712
+ },
6713
+ }),
6714
+ );
6715
+ }
6177
6716
  items.push(
6178
6717
  menuItem(t("account.dashboard"), {
6179
6718
  run: () => {
@@ -6183,7 +6722,14 @@ async function loadAccount() {
6183
6722
  if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
6184
6723
  },
6185
6724
  }),
6186
- menuSep(),
6725
+ menuItem(t("account.download"), {
6726
+ run: () => {
6727
+ closeRowMenu();
6728
+ if (body.site) window.open(body.site + "/" + LOCALE + "/download", "_blank", "noopener");
6729
+ },
6730
+ cls: "accent",
6731
+ }),
6732
+ themeRow(),
6187
6733
  menuItem(t("account.signOut"), {
6188
6734
  run: () => {
6189
6735
  closeRowMenu();
@@ -6260,13 +6806,18 @@ function websiteSignIn(provider, onSignedIn, onUnavailable) {
6260
6806
  * redirect back to, so the browser route is shut and the emailed code is the
6261
6807
  * way in. The caller is the one that can put that where the user is looking.
6262
6808
  */
6263
- function startWebsiteSignIn(providerId, onSignedIn, button, onUnavailable) {
6809
+ function startWebsiteSignIn(providerId, onSignedIn, button, onUnavailable, intent, onError) {
6264
6810
  if (button) button.disabled = true;
6265
- void api("/signin/browser", { provider: providerId, locale: LOCALE })
6811
+ void api("/signin/browser", { provider: providerId, locale: LOCALE, ...(intent ? { intent } : {}) })
6266
6812
  .then((result) => {
6267
6813
  if (!result || result.error) {
6268
6814
  const message = (result && result.error) || "could not start the sign-in";
6269
6815
  if (result && result.fallback === "code" && onUnavailable) return void onUnavailable(message);
6816
+ // A caller with a dialog open has somewhere better to put this.
6817
+ // addNotice writes to the transcript when no panel is up, so an error
6818
+ // from a button in a modal would arrive behind the modal, under an
6819
+ // assistant heading, reading as something the model said.
6820
+ if (onError) return void onError(message);
6270
6821
  return void addNotice(message, "err");
6271
6822
  }
6272
6823
  // Only now: a sign-in that never started has nothing to wait for, and a
@@ -6297,6 +6848,23 @@ const PAYWALL_POLL_MS = 5000;
6297
6848
  let paywallTimer = null;
6298
6849
 
6299
6850
  /** Credits as a person reads them, from the micro-credits the ledger keeps. */
6851
+ /**
6852
+ * What a plan costs, in the currency it is sold in.
6853
+ *
6854
+ * The gateway has always sent amountCents and currency and the paywall threw
6855
+ * them away, so every row said how many credits and never how much money —
6856
+ * which is the one number somebody deciding between four rows needs.
6857
+ */
6858
+ function moneyOf(cents, currency) {
6859
+ if (typeof cents !== "number") return "";
6860
+ return (cents / 100).toLocaleString(LOCALE, {
6861
+ style: "currency",
6862
+ currency: (currency || "usd").toUpperCase(),
6863
+ minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
6864
+ maximumFractionDigits: 2,
6865
+ });
6866
+ }
6867
+
6300
6868
  function creditsOf(micro) {
6301
6869
  return (Number(micro || 0) / 1000000).toLocaleString(LOCALE, {
6302
6870
  minimumFractionDigits: 0,
@@ -6329,11 +6897,51 @@ function closePaywall() {
6329
6897
  * never talks to — so the way this screen learns it worked is by watching the
6330
6898
  * balance, which is also the only number that was ever the real answer.
6331
6899
  */
6332
- function openPaywall() {
6900
+ /** Which interval the paywall is showing. Monthly by default: it is the
6901
+ * smaller commitment, and this dialog is interrupting somebody who wanted to
6902
+ * send a message, not shopping. */
6903
+ /**
6904
+ * Light, dark, or whatever the system says.
6905
+ *
6906
+ * Three states rather than a switch, because following the system was the
6907
+ * behaviour before this existed and taking it away to gain a toggle is a
6908
+ * trade nobody asked for. Stored per browser: it describes this screen in
6909
+ * this room, not the account.
6910
+ */
6911
+ const THEMES = ["system", "light", "dark"];
6912
+ let theme = "system";
6913
+ try {
6914
+ const saved = localStorage.getItem("oharness-theme");
6915
+ if (THEMES.indexOf(saved) > 0) theme = saved;
6916
+ } catch {
6917
+ // Private windows and blocked site data both throw here. Following the
6918
+ // system is the right answer when the preference cannot be read.
6919
+ }
6920
+ function applyTheme() {
6921
+ if (theme === "system") document.documentElement.removeAttribute("data-theme");
6922
+ else document.documentElement.setAttribute("data-theme", theme);
6923
+ try {
6924
+ if (theme === "system") localStorage.removeItem("oharness-theme");
6925
+ else localStorage.setItem("oharness-theme", theme);
6926
+ } catch {
6927
+ // Unstorable is not unusable: it applies for this tab and forgets after.
6928
+ }
6929
+ }
6930
+ applyTheme();
6931
+
6932
+ let pwInterval = "month";
6933
+
6934
+ /**
6935
+ * @param buying Opened deliberately, rather than by a turn that could not be
6936
+ * paid for. The screen is the same and the sentence at the top is not: a
6937
+ * dialog that says "this turn could not be paid for" to somebody who chose
6938
+ * Subscribe from a menu is describing a failure that did not happen.
6939
+ */
6940
+ function openPaywall(buying) {
6333
6941
  const dialog = document.getElementById("paywall");
6334
6942
  const body = document.getElementById("pw-body");
6335
- document.getElementById("pw-title").textContent = t("paywall.title");
6336
- document.getElementById("pw-desc").textContent = t("paywall.desc");
6943
+ document.getElementById("pw-title").textContent = t(buying ? "paywall.titleBuy" : "paywall.title");
6944
+ document.getElementById("pw-desc").textContent = t(buying ? "paywall.descBuy" : "paywall.desc");
6337
6945
 
6338
6946
  let site = "";
6339
6947
  // Whether a checkout has been opened in a browser tab. Kept as state rather
@@ -6355,7 +6963,11 @@ function openPaywall() {
6355
6963
 
6356
6964
  const buy = (plan, button) => {
6357
6965
  button.disabled = true;
6358
- void api("/billing/checkout", { plan: plan.id, locale: LOCALE })
6966
+ void api("/billing/checkout", {
6967
+ plan: plan.id,
6968
+ locale: LOCALE,
6969
+ ...(plan.buyCents ? { amount: plan.buyCents } : {}),
6970
+ })
6359
6971
  .then((result) => {
6360
6972
  if (!result || result.error || !result.url) {
6361
6973
  return void addNotice((result && result.error) || "could not start the checkout", "err");
@@ -6423,26 +7035,157 @@ function openPaywall() {
6423
7035
  );
6424
7036
 
6425
7037
  const plans = state.plans || [];
7038
+ // payg is configured on the site, not bought here — the gateway refuses a
7039
+ // checkout for it — so it is not a row. The account page button below is
7040
+ // where it lives.
6426
7041
  const packs = plans.filter((plan) => plan.kind === "topup");
6427
7042
  const subs = plans.filter((plan) => plan.kind === "subscription");
6428
- const ordered = subscribed ? packs.concat(subs) : subs.concat(packs);
6429
7043
 
6430
- if (!ordered.length) {
7044
+ if (!subs.length && !packs.length) {
6431
7045
  body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
6432
7046
  return;
6433
7047
  }
6434
7048
 
6435
- const list = el("div", "pw-plans");
6436
- ordered.forEach((plan) => {
6437
- const row = el("button", "pw-plan");
7049
+ /**
7050
+ * What a yearly rung saves against paying monthly for the same tier.
7051
+ *
7052
+ * The only thing on these cards nobody can work out by looking. Monthly is
7053
+ * one price per credit across all four tiers — buying up buys volume, not
7054
+ * a discount — so a rate printed on those rows would be the same number
7055
+ * four times. The year is where the money actually differs, and seeing it
7056
+ * takes multiplying a price by twelve in your head.
7057
+ *
7058
+ * Computed rather than configured, so it cannot disagree with what the
7059
+ * checkout charges. Zero is returned as nothing: Starter's year is twelve
7060
+ * months at the same price, and a "Save 0%" badge on it would be a worse
7061
+ * lie than silence.
7062
+ */
7063
+ const savingOn = (plan) => {
7064
+ if (plan.interval !== "year" || !plan.tier) return 0;
7065
+ const monthly = subs.find((p) => p.tier === plan.tier && p.interval === "month");
7066
+ if (!monthly || typeof monthly.amountCents !== "number" || typeof plan.amountCents !== "number") return 0;
7067
+ const full = monthly.amountCents * 12;
7068
+ if (full <= 0 || plan.amountCents >= full) return 0;
7069
+ return Math.round((1 - plan.amountCents / full) * 100);
7070
+ };
7071
+
7072
+ /** One row: what it is, how many credits, and how much money. */
7073
+ const planRow = (plan, suffix) => {
7074
+ const row = el("button", "pw-plan" + (plan.popular ? " featured" : ""));
6438
7075
  row.type = "button";
6439
- const label = plan.kind === "topup" ? t("paywall.topUp") : t("paywall.subscribe");
6440
- row.appendChild(el("span", null, label + " \u00b7 " + (plan.label || plan.id)));
6441
- row.appendChild(el("span", "pw-plan-credits", creditsOf(plan.creditsMicro)));
7076
+ // Two lines rather than one, because two of these sit side by side on a
7077
+ // wide screen and the three-column row they used to be does not survive
7078
+ // being halved. The line that decides is on top.
7079
+ const top = el("span", "pw-plan-top");
7080
+ const name = el("span", "pw-plan-name");
7081
+ name.appendChild(document.createTextNode(plan.label || plan.id));
7082
+ // The catalogue marks one rung. Saying so is the whole of the advice
7083
+ // this dialog can give about which to pick.
7084
+ if (plan.popular) name.appendChild(el("span", "pw-tag", t("paywall.popular")));
7085
+ top.appendChild(name);
7086
+ const money = moneyOf(plan.amountCents, plan.currency);
7087
+ if (money) top.appendChild(el("span", "pw-plan-price", money + (suffix || "")));
7088
+ row.appendChild(top);
7089
+ // The pack's own label is already "2,000 credits", so repeating the
7090
+ // number underneath it said the same thing twice. Subscriptions need
7091
+ // the line — their names are tiers, not amounts.
7092
+ // The same sentence the pricing page prints, for the same reason it
7093
+ // prints it: "12,000 credits" does not say whether that arrives once or
7094
+ // twelve times, and the answer is the second one. A subscription is an
7095
+ // allowance, and a card that states a total without its period is
7096
+ // describing a top-up.
7097
+ const count = creditsOf(plan.creditsMicro);
7098
+ const amount =
7099
+ plan.kind === "subscription"
7100
+ ? t(plan.interval === "year" ? "paywall.everyYear" : "paywall.everyMonth", { credits: count })
7101
+ : count + " " + t("paywall.unit");
7102
+ const saved = savingOn(plan);
7103
+ if ((plan.label || "") !== amount || saved) {
7104
+ const under = el("span", "pw-plan-under");
7105
+ if ((plan.label || "") !== amount) under.appendChild(el("span", null, amount));
7106
+ // Under the price it is about, not beside the name. The badge by the
7107
+ // name says which rung to pick; this one says what the year costs.
7108
+ if (saved) under.appendChild(el("span", "pw-save", t("paywall.save", { percent: saved })));
7109
+ row.appendChild(under);
7110
+ }
6442
7111
  row.onclick = () => buy(plan, row);
6443
- list.appendChild(row);
6444
- });
6445
- body.appendChild(list);
7112
+ return row;
7113
+ };
7114
+
7115
+ // Four tiers sold two ways came out as eight rows with four repeated
7116
+ // names and nothing saying which was which. They are the same four
7117
+ // offers; the interval is a choice about them, so it is a control rather
7118
+ // than eight buttons.
7119
+ const byInterval = { month: subs.filter((p) => p.interval === "month"),
7120
+ year: subs.filter((p) => p.interval === "year") };
7121
+ const intervals = ["month", "year"].filter((k) => byInterval[k].length > 0);
7122
+ const other = subs.filter((p) => p.interval !== "month" && p.interval !== "year");
7123
+
7124
+ if (subs.length) {
7125
+ if (intervals.length > 1) {
7126
+ const toggle = el("div", "pw-toggle");
7127
+ intervals.forEach((key) => {
7128
+ const tab = el("button", "pw-tab" + (key === pwInterval ? " on" : ""));
7129
+ tab.type = "button";
7130
+ tab.textContent = t(key === "year" ? "paywall.yearly" : "paywall.monthly");
7131
+ tab.onclick = () => {
7132
+ pwInterval = key;
7133
+ render(state);
7134
+ };
7135
+ toggle.appendChild(tab);
7136
+ });
7137
+ body.appendChild(toggle);
7138
+ }
7139
+ const showing = intervals.length > 1 ? byInterval[pwInterval] || [] : subs;
7140
+ const list = el("div", "pw-plans");
7141
+ showing.concat(other).forEach((plan) => {
7142
+ list.appendChild(planRow(plan, t(plan.interval === "year" ? "paywall.perYear" : "paywall.perMonth")));
7143
+ });
7144
+ body.appendChild(list);
7145
+ }
7146
+
7147
+ // The one-off, at the bottom, for whoever already has a plan. A top-up
7148
+ // covers a month that ran short rather than standing in for a subscription,
7149
+ // and the gateway refuses one from an account with none — so without a
7150
+ // plan this is the sentence saying so rather than rows that end in a
7151
+ // refusal after the click. The rows come back the moment there is a
7152
+ // subscription to top up.
7153
+ if (packs.length && !subscribed) {
7154
+ body.appendChild(el("div", "cn-note", t("paywall.needsPlan")));
7155
+ } else if (packs.length) {
7156
+ body.appendChild(el("div", "pw-sep", t("paywall.orOnce")));
7157
+ const once = el("div", "pw-plans");
7158
+ packs.forEach((plan) => {
7159
+ // A pack is one rung of a ladder the catalogue already defines, and
7160
+ // the paywall was showing the bottom one as though it were the whole
7161
+ // offer. The presets are the amounts the catalogue means people to
7162
+ // pick from; the rate beside them turns each into its credits, so the
7163
+ // rows cannot disagree with what the checkout grants.
7164
+ const ladder = plan.custom;
7165
+ const presets = ladder && ladder.presetCents ? ladder.presetCents : [];
7166
+ if (!presets.length) return void once.appendChild(planRow(plan, ""));
7167
+ presets.forEach((cents) => {
7168
+ const micro = (ladder.creditsMicroPerCent || 0) * cents;
7169
+ once.appendChild(
7170
+ planRow(
7171
+ {
7172
+ ...plan,
7173
+ // Not the pack's own label: at $50 that would still read
7174
+ // "2,000 credits", which is the wrong number on the button.
7175
+ label: creditsOf(micro) + " " + t("paywall.unit"),
7176
+ creditsMicro: micro,
7177
+ amountCents: cents,
7178
+ // What the checkout is told. Absent on a rung that is the
7179
+ // pack's own price, so that purchase stays the plain one.
7180
+ ...(cents === plan.amountCents ? {} : { buyCents: cents }),
7181
+ },
7182
+ "",
7183
+ ),
7184
+ );
7185
+ });
7186
+ });
7187
+ body.appendChild(once);
7188
+ }
6446
7189
  if (awaiting) body.appendChild(el("div", "cn-note", t("paywall.opened")));
6447
7190
  };
6448
7191
 
@@ -6781,6 +7524,18 @@ async function showSettings() {
6781
7524
  });
6782
7525
  field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
6783
7526
 
7527
+ /*
7528
+ * Memory sits here rather than in the sidebar, and only when a plugin has
7529
+ * actually registered it: a row for a page that does not exist would be a
7530
+ * dead end, and the memory plugin can be switched off.
7531
+ */
7532
+ for (const page of pluginPages.values()) {
7533
+ const open = el("button", "ghost", t("settings.memoryOpen"));
7534
+ open.type = "button";
7535
+ open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
7536
+ field(t("settings.memory"), "", t("settings.memoryHint"), open);
7537
+ }
7538
+
6784
7539
  rulesField(settings.permissions || {});
6785
7540
  auditField(settings.audit || { on: true, source: "user" });
6786
7541
  filesField(settings.settingsPaths);
@@ -7632,6 +8387,157 @@ async function refreshRoutineCount() {
7632
8387
 
7633
8388
  /** Sidebar entries that open a view of their own rather than a conversation. */
7634
8389
  const PANEL_NAV = new Set(["nav-artifacts", "nav-models", "nav-routines", "nav-settings"]);
8390
+ const pluginPages = new Map();
8391
+ async function refreshPluginPages() {
8392
+ const session = activeId;
8393
+ const result = await getJson("/plugin-pages?session=" + encodeURIComponent(session || ""));
8394
+ if (activeId !== session) return;
8395
+ for (const id of pluginPages.keys()) { document.getElementById("nav-plugin-" + id)?.remove(); PANEL_NAV.delete("nav-plugin-" + id); }
8396
+ pluginPages.clear();
8397
+ /*
8398
+ * Remembered, not put in the sidebar.
8399
+ *
8400
+ * A plugin page is reached from a row in Settings instead. The sidebar is
8401
+ * the set of places a session goes back to, and Memory is not one of them —
8402
+ * it is read now and then and adjusted less often than that, which is the
8403
+ * same reason usage lives behind a Settings row rather than beside Models.
8404
+ * Every one of these becomes a SUB_PANEL, so the Settings row it came from
8405
+ * stays lit while it is open.
8406
+ */
8407
+ for (const page of result.pages || []) {
8408
+ if (page.renderer !== "memory") continue;
8409
+ pluginPages.set(page.id, page);
8410
+ SUB_PANELS["nav-plugin-" + page.id] = "nav-settings";
8411
+ }
8412
+ }
8413
+
8414
+ /** Ids for label/control pairs; only has to be unique within the document. */
8415
+ let fieldSeq = 0;
8416
+
8417
+ async function showMemoryPage(id) {
8418
+ const session = activeId;
8419
+ const result = await getJson("/plugin-page?id=" + encodeURIComponent(id) + "&session=" + encodeURIComponent(session || ""));
8420
+ if (shownPanel !== "nav-plugin-" + id || activeId !== session) return;
8421
+ panelBody.replaceChildren(); panelNotes.replaceChildren();
8422
+ if (result.error && !result.entries) return void panelBody.appendChild(panelError(result.error));
8423
+ const panel = el("div", "panel memory-panel"); panelBody.appendChild(panel);
8424
+ panel.appendChild(el("h2", "", t("memory.title")));
8425
+ panel.appendChild(el("p", "muted", t("memory.intro")));
8426
+ panel.appendChild(el("p", "mono small memory-path", result.location));
8427
+ const status = el("p", "", !result.signedIn ? t("memory.localOnly") : result.accountChanged ? t("memory.accountChanged") : !result.enabled ? t("memory.syncOff") : result.syncing ? t("memory.syncing") : t("memory.syncOn"));
8428
+ panel.appendChild(status);
8429
+ if (result.lastSync) panel.appendChild(el("p", "muted small", t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) })));
8430
+ if (result.error) panel.appendChild(panelError(result.error));
8431
+ const act = async (input) => {
8432
+ try {
8433
+ const reply = await api("/plugin-page", { page: id, session, input }, 120000);
8434
+ if (reply.error) throw new Error(reply.error);
8435
+ await showMemoryPage(id);
8436
+ } catch (error) {
8437
+ if (activeId === session && shownPanel === "nav-plugin-" + id) {
8438
+ await showMemoryPage(id);
8439
+ panelBody.appendChild(panelError(error.message || t("memory.failed")));
8440
+ }
8441
+ }
8442
+ };
8443
+ const controls = el("div", ""); controls.style.cssText = "display:flex;gap:12px;align-items:center;margin:16px 0";
8444
+ const toggle = el("button", "ghost", result.enabled ? t("memory.turnOff") : t("memory.turnOn"));
8445
+ toggle.setAttribute("role", "switch"); toggle.setAttribute("aria-label", t("memory.switchLabel")); toggle.setAttribute("aria-checked", String(result.enabled));
8446
+ toggle.disabled = !result.enabled && (!result.signedIn || result.accountChanged);
8447
+ toggle.onclick = async () => { toggle.disabled = true; await act({ action: "enable", enabled: !result.enabled }); toggle.disabled = false; };
8448
+ controls.appendChild(toggle);
8449
+ if (result.signedIn) {
8450
+ const sync = el("button", "ghost", t("memory.syncNow")); sync.disabled = !result.ready || result.syncing;
8451
+ sync.onclick = async () => { sync.disabled = true; status.textContent = t("memory.syncing"); await act({ action: "sync" }); sync.disabled = false; };
8452
+ controls.appendChild(sync);
8453
+ }
8454
+ const refresh = el("button", "ghost", t("memory.refresh")); refresh.onclick = () => { void showMemoryPage(id); }; controls.appendChild(refresh);
8455
+ panel.appendChild(controls);
8456
+ for (const conflict of result.conflicts || []) {
8457
+ const row = el("section", "row-card"); row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
8458
+ for (const [label, note] of [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]]) {
8459
+ row.appendChild(el("h4", "", label)); const text = el("pre", "small", note ? note.summary + "\n" + note.body : t("memory.deleted")); text.style.whiteSpace = "pre-wrap"; row.appendChild(text);
8460
+ }
8461
+ for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")]]) {
8462
+ const button = el("button", "ghost", label); button.onclick = () => { void act({ action: "resolve", id: conflict.id, choice }); }; row.appendChild(button);
8463
+ }
8464
+ panel.appendChild(row);
8465
+ }
8466
+ const editor = (note, extra) => {
8467
+ const form = el("form", "memory-editor"), summary = el("input", ""), body = el("textarea", ""), tags = el("input", "");
8468
+ summary.placeholder = t("memory.summaryPlaceholder"); summary.value = note?.summary || ""; summary.required = true;
8469
+ body.placeholder = t("memory.bodyPlaceholder"); body.value = note?.body || ""; body.rows = 6; body.required = true;
8470
+ tags.placeholder = t("memory.tagsPlaceholder"); tags.value = (note?.tags || []).join(", ");
8471
+ /*
8472
+ * A label each, and a line saying what the field is for.
8473
+ *
8474
+ * Three boxes with only placeholders said nothing at all once they had
8475
+ * content in them, which is every note anyone opens to edit. And the fact
8476
+ * worth knowing here is not which box is which — it is that only the
8477
+ * summary is ever in front of the model, so a vague one makes the note
8478
+ * unfindable no matter how good the body is.
8479
+ */
8480
+ const labelled = (control, label, hint) => {
8481
+ const group = el("div", "memory-field");
8482
+ const name = el("label", "memory-label", label);
8483
+ control.id = "mem-" + (fieldSeq += 1);
8484
+ name.htmlFor = control.id;
8485
+ group.appendChild(name);
8486
+ if (hint) group.appendChild(el("div", "muted small", hint));
8487
+ control.style.cssText = "display:block;width:100%;box-sizing:border-box";
8488
+ group.appendChild(control);
8489
+ return group;
8490
+ };
8491
+ form.appendChild(labelled(summary, t("memory.summaryLabel"), t("memory.summaryHint")));
8492
+ form.appendChild(labelled(body, t("memory.bodyLabel"), t("memory.bodyHint")));
8493
+ form.appendChild(labelled(tags, t("memory.tagsLabel"), t("memory.tagsHint")));
8494
+ const actions = el("div", "memory-actions");
8495
+ const save = el("button", "ghost", note ? t("memory.save") : t("memory.add")); save.type = "submit"; actions.appendChild(save);
8496
+ // Delete belongs beside Save, not stranded under the card: they are the two
8497
+ // things this form can do, and stacking them read as two separate sections.
8498
+ if (extra) actions.appendChild(extra);
8499
+ form.appendChild(actions);
8500
+ form.onsubmit = async (event) => { event.preventDefault(); save.disabled = true;
8501
+ await act({ action: "save", ...(note ? { id: note.id, expectedHash: note.hash } : {}), note: { summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean) } }); save.disabled = false;
8502
+ };
8503
+ return form;
8504
+ };
8505
+ const add = el("details", "row-card memory-note");
8506
+ const addHead = el("summary", "");
8507
+ addHead.appendChild(el("span", "chev"));
8508
+ addHead.appendChild(el("span", "memory-note-name", t("memory.add")));
8509
+ add.appendChild(addHead); add.appendChild(editor()); panel.appendChild(add);
8510
+ const search = el("input", "memory-search"); search.placeholder = t("memory.search"); search.setAttribute("aria-label", t("memory.search")); search.style.margin = "16px 0"; panel.appendChild(search);
8511
+ const list = el("div", ""); panel.appendChild(list);
8512
+ const draw = () => {
8513
+ list.replaceChildren(); const query = search.value.toLowerCase();
8514
+ const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ")).toLowerCase().includes(query));
8515
+ if (!entries.length) list.appendChild(el("p", "muted", t("memory.none")));
8516
+ for (const note of entries) {
8517
+ const row = el("details", "row-card memory-note");
8518
+ /*
8519
+ * Summary and id on one line, the way a settings row is built.
8520
+ *
8521
+ * The id is how the model refers to this note and how a person quotes it
8522
+ * in a bug report, so it stays visible — but it is a handle, not content,
8523
+ * and on a line of its own it read as the first thing in the note. On the
8524
+ * same line as the summary it also stops being a third left edge: the
8525
+ * disclosure marker indents the summary text, so anything under it lined
8526
+ * up with neither the marker nor the fields below.
8527
+ */
8528
+ const head = el("summary", "");
8529
+ head.appendChild(el("span", "chev"));
8530
+ head.appendChild(el("span", "memory-note-name", note.summary));
8531
+ head.appendChild(el("span", "memory-id mono", note.id));
8532
+ row.appendChild(head);
8533
+ const remove = el("button", "ghost", t("memory.delete")); remove.type = "button";
8534
+ remove.onclick = () => { if (confirm(t("memory.confirmDelete"))) void act({ action: "delete", id: note.id, expectedHash: note.hash }); };
8535
+ row.appendChild(editor(note, remove));
8536
+ list.appendChild(row);
8537
+ }
8538
+ };
8539
+ search.oninput = draw; draw();
8540
+ }
7635
8541
 
7636
8542
  /**
7637
8543
  * Panels reached from a row inside another panel rather than from the sidebar.
@@ -7721,6 +8627,7 @@ function paintProject() {
7721
8627
 
7722
8628
  /** Repaint whichever panel is on screen, if one is. */
7723
8629
  function repaintPanel() {
8630
+ void refreshPluginPages();
7724
8631
  if (panelView.hidden) return;
7725
8632
  // Off what was opened, not off which sidebar row is lit: a sub-panel lights
7726
8633
  // the row it was reached from, so asking the sidebar would answer "settings"
@@ -7730,6 +8637,7 @@ function repaintPanel() {
7730
8637
  else if (shownPanel === "nav-routines") void showRoutines();
7731
8638
  else if (shownPanel === "nav-settings") void showSettings();
7732
8639
  else if (shownPanel === "usage") void showUsage();
8640
+ else if (shownPanel?.startsWith("nav-plugin-")) void showMemoryPage(shownPanel.slice("nav-plugin-".length));
7733
8641
  }
7734
8642
 
7735
8643
  async function loadProjects() {
@@ -8277,6 +9185,7 @@ void (async () => {
8277
9185
  }
8278
9186
  await loadSessions();
8279
9187
  await refreshRoutineCount();
9188
+ await refreshPluginPages();
8280
9189
  await refreshModelCount();
8281
9190
  await loadAccount();
8282
9191
  input.focus();