@oharness/server 0.4.0 → 0.5.1

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
@@ -1,6 +1,7 @@
1
1
  import { CLIENT_SCRIPT } from "./markdown.js";
2
2
  import { ANALYTICS_CLIENT, ANALYTICS_STUB } from "./analytics.js";
3
3
  import { LOCALES, STRINGS } from "./i18n.js";
4
+ import { THEME_CSS } from "./theme.js";
4
5
  /**
5
6
  * The browser page, inlined.
6
7
  *
@@ -16,48 +17,9 @@ import { LOCALES, STRINGS } from "./i18n.js";
16
17
  * rather than a default, and the reason it is one variable to grep for rather
17
18
  * than a setting buried in a config file.
18
19
  */
20
+ // The palette lives in theme.ts; everything below reads it through var().
19
21
  const STYLE = String.raw `
20
- :root{
21
- /* Native scrollbars and form controls read this, not our variables: without
22
- it every scroller keeps a white track in dark mode. */
23
- color-scheme:light;
24
- --bg:#fff; --panel:#f7f7f8; --fg:#1a1a1a; --dim:#6b7280; --line:#00000010;
25
- --accent:#111827; --ok:#15803d; --warn:#b45309; --err:#b91c1c;
26
- --add:#065f46; --add-bg:#ecfdf5; --del:#991b1b; --del-bg:#fef2f2;
27
- --hover:#00000008; --sel:#00000012; --focus:#111827; --ring:#1118271a;
28
- --field:#00000006; --field-focus:#0000000d; --track:#0000001f;
29
- /* What separates the mention chip from the field behind it. A shadow on
30
- paper; on the dark theme a shadow falls on something already black, so it
31
- is the faintest of light edges instead. Neither is a border: a hard ring
32
- made each mention read as a small input field inside a sentence. */
33
- --mention-lift:0 1px 2px #0000000f, 0 0 0 1px #0000000a;
34
- --kw:#7c3aed; --str:#047857; --com:#94a3b8; --num:#b45309;
35
- }
36
- /* The dark palette, reached two ways: the system asked for it and nothing
37
- here overrode that, or somebody picked it. Written once and used by both
38
- selectors — two copies of twenty colours is two places for one of them to
39
- drift. */
40
- @media (prefers-color-scheme:dark){
41
- :root:not([data-theme="light"]){
42
- color-scheme:dark;
43
- --bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
44
- --accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
45
- --add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
46
- --hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
47
- --field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
48
- --mention-lift:0 0 0 1px #ffffff1f, 0 1px 3px #00000073;
49
- --kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
50
- }
51
- }
52
- :root[data-theme="dark"]{
53
- color-scheme:dark;
54
- --bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
55
- --accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
56
- --add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
57
- --hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
58
- --field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
59
- --kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
60
- }
22
+ ${THEME_CSS}
61
23
  *{box-sizing:border-box}
62
24
  /* The hidden attribute works through the browser's own display:none, which is
63
25
  a bare element rule any class with a display beats — so .hidden = true left
@@ -134,7 +96,6 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
134
96
  .fold .ico-sm{width:16px;height:16px;flex:0 0 auto;opacity:.5}
135
97
  .fold.open .ico-sm,.fold:hover .ico-sm{opacity:.9}
136
98
  .fold-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
137
- .fold-count{font-size:11px;color:var(--dim);flex:0 0 auto}
138
99
  /* A gap between projects, but not above the first one. The selector names the
139
100
  row rather than the button since the header grew a menu control beside it. */
140
101
  .fold-row + .fold-row,.session + .fold-row,.show-more + .fold-row{margin-top:6px}
@@ -205,6 +166,14 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
205
166
  }
206
167
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
207
168
  :root[data-theme="dark"] .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
169
+ /* The account menu is as wide as the account row it opens from, edge to edge.
170
+ Sized for a session row's three commands, it came out a third narrower than
171
+ the row under it, so the two read as unrelated boxes; the width is set on
172
+ open from the row itself, and this lifts the caps that would clamp it. */
173
+ .row-menu.fit-anchor{min-width:0;max-width:none}
174
+ .row-menu.account-menu{
175
+ padding:10px;max-height:calc(100vh - 24px);overflow-y:auto;
176
+ }
208
177
  /*
209
178
  * Destructive is carried by the label's colour, not by a filled red bar. A
210
179
  * solid block reads as an alert that has already happened rather than as a
@@ -223,8 +192,46 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
223
192
  .row-menu .menu-head{
224
193
  padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
225
194
  }
195
+ /* A section label, not a surface: the fill belongs to the account's facts
196
+ above, and on a one-word caption it read as a second card pressed against
197
+ Dashboard. Space above it does the separating instead. */
198
+ .row-menu .menu-head.menu-section{
199
+ margin:10px 0 2px;padding:4px 10px;background:transparent;
200
+ }
226
201
  .menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
227
202
  .menu-head-sub{margin-top:2px;font-size:12px;color:var(--dim)}
203
+ /* The billing card borrows .menu-head's surface but not its caption type: the
204
+ uppercase and tracking belong to section labels, and on a balance and a
205
+ plan name they turned two facts into one shouted line. */
206
+ .row-menu .menu-head.account-card{text-transform:none;letter-spacing:0;font-weight:400;font-size:12.5px}
207
+ .menu-head-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
208
+ .menu-head-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dim)}
209
+ .plan-pill{
210
+ display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;max-width:60%;
211
+ padding:2px 8px;border-radius:999px;background:var(--field);color:var(--dim);
212
+ font-size:11.5px;font-weight:500;white-space:nowrap;
213
+ }
214
+ .plan-pill-dot{width:6px;height:6px;flex:0 0 auto;border-radius:50%;background:currentColor;opacity:.7}
215
+ .plan-pill-name{overflow:hidden;text-overflow:ellipsis}
216
+ .plan-pill-status{font-weight:400;opacity:.8}
217
+ /* Live is the brand's lime, the colour the app gives to things that are on. */
218
+ .plan-pill.ok{background:var(--brand-tint);color:var(--brand-ink)}
219
+ .plan-pill.ok .plan-pill-dot{background:var(--brand);opacity:1}
220
+ .plan-pill.warn{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
221
+ .plan-pill.warn .plan-pill-dot{opacity:1}
222
+ .menu-head-amount{display:flex;align-items:baseline;gap:5px;margin-top:6px}
223
+ .menu-head-num{font-size:20px;font-weight:600;letter-spacing:-.01em;line-height:1.2;color:var(--fg);font-variant-numeric:tabular-nums}
224
+ .menu-head-unit{font-size:12px;color:var(--dim)}
225
+ .menu-billing-actions{display:flex;gap:7px;margin-top:10px}
226
+ .menu-billing-action{
227
+ flex:1;min-height:30px;padding:6px 10px;border:0;border-radius:7px;
228
+ background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line);
229
+ font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;
230
+ }
231
+ .menu-billing-action:hover{background:var(--hover)}
232
+ .menu-billing-action.primary{background:var(--accent);color:var(--bg);box-shadow:none}
233
+ .menu-billing-action.primary:hover{opacity:.88}
234
+ .menu-billing-action:disabled{cursor:not-allowed;opacity:.45}
228
235
  /* One entry carrying the accent, for the one that is an offer rather than a
229
236
  destination. var(--field) was too close to the menu's own surface to read as
230
237
  anything in the light theme — a highlight nobody can see is worse than none,
@@ -411,6 +418,11 @@ svg.vglyph{width:15px;height:15px;opacity:1;
411
418
  the composer bar's 2px does not provide. */
412
419
  .menu-group .vmark,.menu-group .vmono,.menu-group .vglyph{width:13px;height:13px;font-size:8px}
413
420
  .picker-btn .vmark,.picker-btn .vmono,.picker-btn .vglyph{width:14px;height:14px;margin-right:5px}
421
+ /* The mode's glyph: a bare stroke, so it takes the row's icon weight and, on
422
+ the bar, the vendor mark's size and gap so the two pickers line up. */
423
+ .menu-item .mode-mark{opacity:.7}
424
+ .menu-item.on .mode-mark,.menu-item.cursor .mode-mark{opacity:1}
425
+ .picker-btn .mode-mark{width:14px;height:14px;margin-right:5px;opacity:.85}
414
426
  /* On a card the row is a title over a line of description, so the mark is
415
427
  sized against the block rather than against the type, and sits on the
416
428
  title's line instead of floating in the middle of the two. */
@@ -492,8 +504,18 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
492
504
  * Tabs are sessions; the drawer beside them shows one artifact whichever tab
493
505
  * is in front. Spanning both left a band of empty chrome above the drawer that
494
506
  * belonged to neither.
507
+ *
508
+ * The strip is the shelf the tabs stand on, in the sidebar's grey, and the
509
+ * front tab is cut from the page under it — the shape every browser uses.
510
+ * It used to be the other way round: a transparent strip on the white
511
+ * transcript with the front tab as a grey pill, so the row read as part of
512
+ * the conversation rather than as the chrome above it. No bottom padding,
513
+ * or a grey line would sit between the front tab and the page it is part of.
495
514
  */
496
- #tabs{display:flex;align-items:center;gap:2px;padding:0 12px 2px;overflow-x:auto;flex:0 0 auto}
515
+ #tabs{
516
+ display:flex;align-items:center;gap:2px;padding:6px 12px 0;overflow-x:auto;flex:0 0 auto;
517
+ background:var(--panel);
518
+ }
497
519
  #tabs:empty{display:none}
498
520
  .tab{
499
521
  display:flex;align-items:center;gap:7px;max-width:190px;height:30px;padding:0 6px 0 10px;
@@ -501,7 +523,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
501
523
  font:inherit;font-size:12.5px;cursor:pointer;flex:0 0 auto;
502
524
  }
503
525
  .tab:hover{background:var(--hover);color:var(--fg)}
504
- .tab.on{background:var(--panel);color:var(--fg)}
526
+ .tab.on{background:var(--bg);color:var(--fg)}
505
527
  .tab .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
506
528
  /* Which project a tab is in, shown only when the open tabs span more than one:
507
529
  two transcripts called "Untitled" in different trees are otherwise the same
@@ -511,7 +533,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
511
533
  font-size:11px;opacity:.6;flex:0 0 auto;
512
534
  }
513
535
  .tab .where::after{content:"/";padding-left:5px;opacity:.5}
514
- .tab .dot{width:6px;height:6px;border-radius:50%;background:var(--warn);flex:0 0 auto}
536
+ .tab .dot{width:6px;height:6px;border-radius:50%;background:var(--brand);flex:0 0 auto}
515
537
  .tab .x{
516
538
  width:17px;height:17px;border:0;border-radius:5px;background:transparent;color:inherit;
517
539
  font:inherit;font-size:14px;line-height:1;cursor:pointer;opacity:0;flex:0 0 auto;
@@ -562,7 +584,7 @@ body.panelled #composer,body.panelled #jump,body.panelled #changes-btn{display:n
562
584
  border-left:2px solid transparent;
563
585
  }
564
586
  .file:hover{background:var(--bg)}
565
- .file.active{background:var(--bg);border-left-color:var(--accent)}
587
+ .file.active{background:var(--sel)}
566
588
  .file .p{font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
567
589
  .file .n{font-size:11px;font-family:ui-monospace,monospace}
568
590
  .file .plus{color:var(--ok)} .file .minus{color:var(--err)}
@@ -633,12 +655,12 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
633
655
  border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);
634
656
  transition:transform .15s;
635
657
  }
636
- .switch[aria-checked="true"]{background:var(--accent)}
658
+ .switch[aria-checked="true"]{background:var(--brand)}
637
659
  .switch[aria-checked="true"]::after{transform:translateX(14px)}
638
660
  /* Disabled means "not yet", not "never": it stays legible, and the row says
639
661
  why. Hiding it instead left people looking for a control that exists. */
640
662
  .switch:disabled{opacity:.45;cursor:default}
641
- .switch:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
663
+ .switch:focus-visible{outline:none;filter:brightness(.9)}
642
664
  .share-btn{
643
665
  border:1px solid var(--line);background:var(--field);color:var(--fg);
644
666
  border-radius:5px;padding:2px 8px;font:inherit;font-size:11px;cursor:pointer;
@@ -676,7 +698,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
676
698
  transcript look like a dialog, and next to the tool cards it sits between —
677
699
  which have none — it was the only thing drawing a box around itself. */
678
700
  .art-card:hover{background:var(--sel)}
679
- .art-card:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
701
+ .art-card:focus-visible{outline:none;background:var(--sel)}
680
702
  .art-card-mark{
681
703
  display:flex;align-items:center;justify-content:center;flex:0 0 auto;
682
704
  width:28px;height:28px;border-radius:7px;background:var(--field);color:var(--fg);
@@ -770,6 +792,60 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
770
792
  * they are answering. */
771
793
  .connection-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:2px}
772
794
  .connection-card .err{color:var(--fg)}
795
+
796
+ /* A question from the agent, in the transcript rather than a modal: answering
797
+ * may take a look back up the conversation, which a modal would cover. Options
798
+ * are filled rows; the chosen one takes the accent fill, never a ring. */
799
+ .question-card{background:var(--panel);border-radius:12px;padding:14px 16px;margin-bottom:10px;display:flex;flex-direction:column;gap:10px;outline:none}
800
+ .q-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
801
+ .q-tag,.q-tab{font:inherit;font-size:11.5px;line-height:1.7;padding:0 9px;border:0;border-radius:999px;background:var(--field);color:var(--dim)}
802
+ .q-tab{cursor:pointer}
803
+ .q-tab:hover{background:var(--hover);color:var(--fg)}
804
+ .q-tab.on{background:var(--sel);color:var(--fg)}
805
+ .q-tab.done::after{content:" ✓"}
806
+ .q-hint{font-size:11.5px;color:var(--dim);margin-left:auto}
807
+ .q-text{font-size:14.5px;line-height:1.5;font-weight:600;overflow-wrap:anywhere}
808
+ .q-layout{display:flex;flex-direction:column;gap:8px;min-width:0}
809
+ .q-options{display:flex;flex-direction:column;gap:4px}
810
+ .q-opt{
811
+ display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;border:0;border-radius:9px;
812
+ padding:8px 10px;background:var(--bg);color:var(--fg);font:inherit;cursor:pointer;
813
+ }
814
+ .q-opt:hover,.q-opt:focus-visible{outline:none;background:var(--sel)}
815
+ .q-opt.on{background:var(--accent);color:var(--bg)}
816
+ .q-opt.on:focus-visible{background:var(--accent);opacity:.9}
817
+ .q-key{
818
+ flex:none;min-width:18px;height:18px;border-radius:5px;background:var(--field);color:var(--dim);
819
+ font-size:11px;display:grid;place-items:center;margin-top:1px;
820
+ }
821
+ .q-opt.on .q-key{background:#ffffff26;color:inherit}
822
+ .q-words{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
823
+ .q-label{font-size:13.5px;line-height:1.45;overflow-wrap:anywhere}
824
+ .q-desc{font-size:12px;line-height:1.45;color:var(--dim);overflow-wrap:anywhere}
825
+ .q-opt.on .q-desc{color:inherit;opacity:.75}
826
+ .q-check{flex:none;width:14px;font-size:12px;line-height:20px}
827
+ .q-other{cursor:text;align-items:center}
828
+ .q-other.on{background:var(--sel)}
829
+ .q-other-input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:13.5px;padding:0}
830
+ .q-preview{
831
+ margin:0;border-radius:9px;background:var(--bg);padding:10px 12px;font-size:12px;line-height:1.55;
832
+ white-space:pre-wrap;overflow:auto;max-height:280px;
833
+ }
834
+ .q-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
835
+ .q-actions .q-keys{margin-right:auto}
836
+ .q-actions button{padding:7px 13px;border:0;border-radius:9px;background:var(--field);color:var(--fg);font:inherit;font-size:13px;cursor:pointer}
837
+ .q-actions button:hover{background:var(--sel)}
838
+ .q-actions button.approve{background:var(--accent);color:var(--bg)}
839
+ .q-actions button.approve:hover{opacity:.88}
840
+ .q-actions button.quiet{background:transparent;color:var(--dim)}
841
+ .q-actions button.quiet:hover{background:var(--hover);color:var(--fg)}
842
+ .q-actions button:disabled{opacity:.45;cursor:default}
843
+ .q-keys{font-size:11.5px;color:var(--dim)}
844
+ .q-error{font-size:12px;color:var(--err)}
845
+ .question-card.settled{gap:4px;padding:10px 14px}
846
+ .q-settled-title{font-size:12px;color:var(--dim)}
847
+ .q-settled-line{display:flex;gap:8px;font-size:13px;line-height:1.5;overflow-wrap:anywhere}
848
+ .q-settled-head{color:var(--dim);flex:none}
773
849
  .who{
774
850
  font-size:11.5px;color:var(--dim);margin-bottom:6px;font-weight:500;letter-spacing:.01em;
775
851
  }
@@ -786,6 +862,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
786
862
  }
787
863
  .user{margin-bottom:18px}
788
864
  .panel{max-width:720px;margin:8px auto 0}
865
+ .panel-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding-block:48px;font-size:13px}
789
866
  .panel h2{font-size:18px;font-weight:600;letter-spacing:-.01em;margin:0 0 14px}
790
867
  /* Only the gap below. The horizontal margin belongs to whatever is placing the
791
868
  * card: setting all four here quietly reset the auto margins that keep a card
@@ -798,14 +875,27 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
798
875
  /* Filled, not outlined — the same shape .art-search uses. A box drawn with a
799
876
  * line around it is the one thing this site does not do, and three of them
800
877
  * stacked made the editor look like a form bolted onto the page rather than
801
- * part of the note. The focus ring stays: that is the one border worth having,
802
- * and it appears only while the field is being used. */
878
+ * part of the note. Focus deepens the fill rather than drawing a ring: no
879
+ * border marks a focused thing anywhere here. */
803
880
  .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}
804
- .memory-panel input:focus,.memory-panel textarea:focus{outline:2px solid var(--ring)}
881
+ .memory-panel input:focus,.memory-panel textarea:focus{outline:none;background:var(--field-focus)}
805
882
  .memory-panel textarea{resize:vertical;min-height:130px}
806
883
  .memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
807
884
  .memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
808
885
  .memory-panel .memory-search{width:100%}
886
+ .memory-summary{padding:14px 16px;margin-bottom:10px}
887
+ .memory-state{font-size:15px;font-weight:600}
888
+ .memory-summary p{margin:4px 0}
889
+ .memory-error{color:var(--warn);overflow-wrap:anywhere}
890
+ .memory-summary .memory-actions{flex-wrap:wrap;align-items:center;margin-top:12px}
891
+ .memory-summary .signin-site{width:auto;flex:0 0 auto;padding:0 16px}
892
+ .memory-about{margin:6px 0 4px;color:var(--dim)}
893
+ .memory-about summary{cursor:pointer;width:max-content}
894
+ /* Prose, so it breaks between words; the shared .small breaks anywhere, which suits a path. */
895
+ .memory-panel .section-title+.memory-notes-intro{margin:-4px 0 10px;word-break:normal;overflow-wrap:anywhere}
896
+ .memory-empty{padding:14px 16px}
897
+ .memory-empty-title{font-size:13.5px;font-weight:500;margin-bottom:4px}
898
+ .memory-empty p{margin:0;word-break:normal;overflow-wrap:anywhere}
809
899
  /* Built like a settings row: one flex line, the marker drawn rather than left
810
900
  * to the browser, so the summary text and everything below it share one left
811
901
  * edge. */
@@ -825,6 +915,18 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
825
915
  /* The form's own background is the page's, which on a card reads as a second
826
916
  * card inside the first one — the same doubling the padding was making. */
827
917
  .memory-actions{display:flex;gap:8px;margin-top:14px}
918
+ .memory-recent{margin:6px 0}
919
+ .memory-recent>summary{cursor:pointer;color:var(--dim)}
920
+ .memory-recent>div{padding:2px 0 2px 15px}
921
+ .memory-backup{display:flex;align-items:center;gap:12px;padding:8px 0 8px 15px}
922
+ .memory-backup-text{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}
923
+ .memory-versions{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin:10px 0}
924
+ .memory-version h4{margin:0 0 6px}
925
+ .memory-version pre{margin:0;background:var(--field);border-radius:8px;padding:10px 12px;overflow-wrap:anywhere}
926
+ .memory-links{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 8px 15px;color:var(--dim)}
927
+ .memory-link{background:var(--field);color:var(--fg);border:0;border-radius:6px;padding:2px 8px;font:inherit;font-size:12px;cursor:pointer}
928
+ .memory-link.missing{color:var(--dim);cursor:default;text-decoration:line-through}
929
+ .memory-panel select{background:var(--field);color:var(--fg);border:0;border-radius:8px;padding:10px 12px;font:inherit}
828
930
  /*
829
931
  * A settings row: name, value, and everything else behind the triangle.
830
932
  *
@@ -845,7 +947,7 @@ details.setting[open]>summary{border-radius:10px 10px 0 0}
845
947
  details.setting[open]>summary .chev{transform:rotate(90deg)}
846
948
  details.setting>summary .ghost{flex:0 0 auto;height:26px;font-size:12px;margin-left:0}
847
949
  details.setting>summary .switch{flex:0 0 auto}
848
- details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
950
+ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
849
951
  /*
850
952
  * The artifacts panel: a search field over the whole catalogue.
851
953
  *
@@ -890,10 +992,19 @@ details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-of
890
992
  /* Lists that could be forty lines long scroll where they are rather than
891
993
  growing the page under them. */
892
994
  .scroll-box{
893
- max-height:200px;overflow-y:auto;border:1px solid var(--line);
995
+ max-height:200px;overflow-y:auto;background:var(--field);
894
996
  border-radius:6px;padding:7px 9px;
895
997
  }
896
998
  .trail-line{padding:2px 0}
999
+ /* On its own page the trail is the page: it flows rather than scrolling in a
1000
+ box, and the box's hairline goes with it — a list this long framed by a
1001
+ rule reads as a field somebody is meant to type in. */
1002
+ .audit-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
1003
+ .panel.audit .audit-list{margin:6px 0 10px}
1004
+ .panel.audit .audit-list .trail-line{padding:3px 0}
1005
+ .setting-body .audit-list{
1006
+ max-height:200px;overflow-y:auto;border-radius:6px;padding:7px 9px;background:var(--field);
1007
+ }
897
1008
  /* A row of things to do with what is above them. Links styled as the ghost
898
1009
  buttons beside them, because a download is a link and pretending otherwise
899
1010
  costs the browser's own save machinery. */
@@ -914,6 +1025,97 @@ details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-of
914
1025
  * and five tints of one colour cannot do it.
915
1026
  */
916
1027
  .seg{display:inline-flex;gap:2px;padding:2px;border-radius:9px;background:var(--field);margin:2px 0 12px}
1028
+ /* The presets and the calendar on one line, on the same baseline. */
1029
+ .usage-window{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
1030
+ .perms-card{display:flex;flex-direction:column;gap:8px}
1031
+ .perms-card .setting-list{margin:0}
1032
+ /* ---- date range picker ----------------------------------------------------
1033
+ The site's own component, class for class. Borderless like everything else
1034
+ here: fills for the chosen days, a shadow to lift the panel, and no ring on
1035
+ any state. */
1036
+ .drp{position:relative;display:inline-flex;margin:2px 0 12px}
1037
+ .drp-trigger{
1038
+ display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 11px;
1039
+ border:0;border-radius:9px;background:var(--field);color:var(--dim);
1040
+ font:inherit;font-size:12.5px;font-variant-numeric:tabular-nums;cursor:pointer;
1041
+ }
1042
+ .drp-trigger:hover{color:var(--fg)}
1043
+ .drp-trigger.on{background:var(--sel);color:var(--fg)}
1044
+ .drp-trigger svg{flex:none;width:14px;height:14px;opacity:.75}
1045
+ .drp-pop{
1046
+ position:absolute;top:calc(100% + 8px);left:0;z-index:30;width:max-content;
1047
+ max-width:calc(100vw - 32px);padding:16px;border-radius:14px;background:var(--bg);
1048
+ box-shadow:0 12px 40px #00000029,0 2px 6px #0000000f;
1049
+ }
1050
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .drp-pop{background:var(--panel)}}
1051
+ :root[data-theme="dark"] .drp-pop{background:var(--panel)}
1052
+ .drp-nav{
1053
+ position:absolute;top:12px;right:12px;left:12px;display:flex;
1054
+ justify-content:space-between;pointer-events:none;
1055
+ }
1056
+ .drp-arrow{
1057
+ display:grid;place-items:center;width:30px;height:30px;padding:0;border:0;
1058
+ border-radius:999px;background:transparent;color:var(--dim);
1059
+ cursor:pointer;pointer-events:auto;
1060
+ }
1061
+ .drp-arrow svg{width:16px;height:16px}
1062
+ .drp-arrow:hover:not(:disabled){background:var(--hover);color:var(--fg)}
1063
+ .drp-arrow:disabled{opacity:.3;cursor:default}
1064
+ .drp-months{display:flex;justify-content:center;gap:28px}
1065
+ .drp-month-name{
1066
+ height:30px;margin-top:-4px;font-size:14px;font-weight:600;line-height:30px;
1067
+ text-align:center;color:var(--fg);
1068
+ }
1069
+ .drp-grid{display:grid;grid-template-columns:repeat(7,34px);grid-auto-rows:34px;row-gap:2px;margin-top:6px}
1070
+ .drp-weekday{display:grid;place-items:center;font-size:11.5px;font-weight:500;color:var(--dim)}
1071
+ /* A day is a full-width cell, so the range fill runs unbroken across a week;
1072
+ the number sits in a circle inside it. */
1073
+ .drp-day{
1074
+ position:relative;display:grid;place-items:center;width:34px;height:34px;padding:0;
1075
+ border:0;border-radius:0;background:transparent;color:var(--fg);
1076
+ font:inherit;font-size:13px;font-variant-numeric:tabular-nums;cursor:pointer;
1077
+ }
1078
+ .drp-day>span{
1079
+ position:relative;display:grid;place-items:center;width:30px;height:30px;
1080
+ border-radius:999px;transition:background .12s ease;
1081
+ }
1082
+ .drp-day:hover:not(:disabled)>span{background:var(--hover)}
1083
+ .drp-day:disabled{color:var(--dim);opacity:.45;cursor:default}
1084
+ .drp-day.is-inside{background:var(--field)}
1085
+ .drp-day.is-start{background:linear-gradient(to right,transparent 50%,var(--field) 50%)}
1086
+ .drp-day.is-end{background:linear-gradient(to left,transparent 50%,var(--field) 50%)}
1087
+ .drp-day.is-edge>span,.drp-day.is-edge:hover>span{background:var(--fg);color:var(--bg);font-weight:600}
1088
+ /* Today: a small dot under the number, so it reads without a ring. */
1089
+ .drp-day.is-today>span::after{
1090
+ content:"";position:absolute;bottom:3px;left:50%;translate:-50% 0;
1091
+ width:4px;height:4px;border-radius:999px;background:currentColor;opacity:.55;
1092
+ }
1093
+ .drp-day:focus-visible{outline:none}
1094
+ .drp-day:focus-visible>span{background:var(--sel)}
1095
+ .drp-foot{
1096
+ display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;
1097
+ gap:10px 16px;margin-top:14px;padding-top:12px;font-size:13px;color:var(--dim);
1098
+ box-shadow:0 -1px 0 var(--line);
1099
+ }
1100
+ .drp-summary strong{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
1101
+ .drp-actions{display:inline-flex;gap:6px;margin-left:auto}
1102
+ .drp-cancel,.drp-apply{
1103
+ padding:7px 14px;border:0;border-radius:10px;font:inherit;font-size:13px;cursor:pointer;
1104
+ }
1105
+ .drp-cancel{background:transparent;color:var(--dim)}
1106
+ .drp-cancel:hover{background:var(--hover);color:var(--fg)}
1107
+ .drp-apply{background:var(--fg);color:var(--bg)}
1108
+ .drp-apply:hover:not(:disabled){opacity:.88}
1109
+ .drp-apply:disabled{opacity:.35;cursor:default}
1110
+ /* One month on a narrow screen — the later one, which holds today — and the
1111
+ panel floats over the middle of it rather than hanging off a trigger the
1112
+ width of the screen cannot accommodate. */
1113
+ @media (max-width:640px){
1114
+ .drp-month.is-first{display:none}
1115
+ .drp-pop{
1116
+ position:fixed;top:18vh;left:50%;width:min(100vw - 32px,300px);translate:-50% 0;
1117
+ }
1118
+ }
917
1119
  .seg-item{
918
1120
  border:0;background:transparent;color:var(--dim);font:inherit;font-size:12.5px;
919
1121
  padding:5px 11px;border-radius:7px;cursor:pointer;
@@ -1001,10 +1203,20 @@ details.provider>summary:hover{background:var(--hover)}
1001
1203
  separate the account from the list — a hairline as well is a second answer
1002
1204
  to a question the whitespace had settled, and it is the only line left in a
1003
1205
  sidebar that has none. */
1206
+ /* A column, not a row. Signed in this used to hold the account button and
1207
+ nothing else, and now a subscriber gets a Top up row above it — the thing
1208
+ a subscriber does most often here, which had been the fourth entry of a
1209
+ menu behind the name. */
1004
1210
  #account{
1005
- display:flex;align-items:center;gap:8px;
1211
+ display:flex;flex-direction:column;align-items:stretch;gap:2px;
1006
1212
  flex:0 0 auto;padding:8px;
1007
1213
  }
1214
+ /* 10px across rather than the nav rows' 8: the account row under it starts
1215
+ with a 24px avatar inset 6px, and at 10px this row's 16px icon lands on
1216
+ the avatar's centre line rather than 4px to the left of it. The balance
1217
+ sits in the aside slot the nav rows keep for a count, because it is what
1218
+ decides whether to click. */
1219
+ #account .topup{width:100%;padding:0 10px}
1008
1220
  /* Signed out this holds one nav row and nothing else, so the padding that
1009
1221
  frames an account goes away and the row sits flush with the ones above it.
1010
1222
  8px across, matching the nav list above, so the icon lands on the same
@@ -1015,7 +1227,8 @@ details.provider>summary:hover{background:var(--hover)}
1015
1227
  taller than the nav rows above: a footer is allowed to be the roomiest row
1016
1228
  in the sidebar, and this one has to hold a click aimed at from the corner. */
1017
1229
  #account.out{padding:4px 8px 10px}
1018
- #account.out .nav-item{width:100%;height:34px}
1230
+ #account .nav-item{width:100%}
1231
+ #account.out .nav-item{height:34px}
1019
1232
  /* The whole row is the control. It used to be a label with a Sign out button
1020
1233
  parked beside it, which gave the rarest action in the sidebar the most
1021
1234
  reliable target in it, and left the name — the thing worth clicking, because
@@ -1083,15 +1296,15 @@ button.signin-site:disabled{opacity:.4;cursor:default}
1083
1296
  * The site's pricing page settled this and says why in its own stylesheet: the
1084
1297
  * offers should read as columns of type rather than as a row of frames, and on
1085
1298
  * a screen that is mostly numbers the frames are the loudest thing on it. So
1086
- * the fill goes quiet and the recommended rung keeps the one outline, which is
1087
- * what makes it a mark rather than a shade somebody has to notice.
1299
+ * the fill goes quiet and the recommended rung takes a deeper fill. No outline
1300
+ * marks anything here, the recommended rung included.
1088
1301
  */
1089
1302
  button.pw-plan{
1090
1303
  display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
1091
1304
  min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
1092
1305
  background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
1093
1306
  }
1094
- button.pw-plan.featured{border-color:var(--border-strong,var(--fg));background:var(--sel)}
1307
+ button.pw-plan.featured{background:var(--sel)}
1095
1308
  .pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
1096
1309
  button.pw-plan:hover{background:var(--sel)}
1097
1310
  button.pw-plan.featured:hover{background:var(--hover)}
@@ -1242,17 +1455,17 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1242
1455
  acts, and a button inside a button is not a thing. The forget stays hidden
1243
1456
  until the row is under the pointer — it is the rarer of the two by a wide
1244
1457
  margin, and a column of them reads as a list of things to delete. */
1245
- /* The project header, plus the one control that acts on the whole project.
1246
- Same shape as a session row's: hidden until the row is hovered, and held
1247
- open while its menu is. */
1458
+ /* The project header, plus the controls that act on the whole project: a new
1459
+ session there, and the menu. Always shown, in place of a count — they are
1460
+ what the header is for. */
1248
1461
  .fold-row{display:flex;align-items:center}
1249
1462
  .fold-row .fold{flex:1;min-width:0}
1250
1463
  .fold-row .more{
1251
- display:none;width:20px;height:20px;border:0;border-radius:5px;background:transparent;
1464
+ display:grid;width:20px;height:20px;border:0;border-radius:5px;background:transparent;
1252
1465
  color:var(--dim);cursor:pointer;padding:0;place-items:center;flex:0 0 auto;margin-right:6px;
1253
1466
  }
1254
- .fold-row:hover .more,.fold-row .fold.menu-open + .more{display:grid}
1255
- .fold-row .more:hover,.fold-row .fold.menu-open + .more{background:var(--sel);color:var(--fg)}
1467
+ .fold-row .fold-new{margin-right:2px}
1468
+ .fold-row .more:hover,.fold-row .fold.menu-open ~ .more:last-child{background:var(--sel);color:var(--fg)}
1256
1469
  .fold-row .more .ico-sm{width:14px;height:14px;opacity:1}
1257
1470
  .proj-row{display:flex;align-items:center;border-radius:8px}
1258
1471
  .proj-row:hover{background:var(--hover)}
@@ -1271,15 +1484,23 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1271
1484
  * here, the projects themselves, and the few actions worth a shortcut. */
1272
1485
  #palette{width:min(560px,92vw);max-height:min(60vh,520px)}
1273
1486
  #palette[open]{display:flex;flex-direction:column}
1274
- #cmdk-list{overflow-y:auto;padding:2px 8px 8px;flex:1}
1487
+ /* One left edge for everything: the field's magnifier, each row's icon, the
1488
+ group labels and the key hints all start 22px in, and the field's text
1489
+ starts where the row labels do. Rows sit 8px in so their highlight has room;
1490
+ their own 14px padding brings the content back to the shared edge. */
1491
+ .cmdk-head{position:relative;padding:12px 12px 8px}
1492
+ .cmdk-head .ico-sm{position:absolute;left:22px;top:calc(50% - 5.5px);pointer-events:none}
1493
+ #palette .cmdk-head input{height:40px;padding:0 10px 0 35px;font-size:14px}
1494
+ #cmdk-list{overflow-y:auto;padding:0 8px 8px;flex:1;scroll-padding-block:4px}
1275
1495
  .cmdk-group{
1276
- padding:9px 8px 4px;font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;
1496
+ padding:12px 14px 5px;font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;
1277
1497
  color:var(--dim);
1278
1498
  }
1499
+ .cmdk-group:first-child{padding-top:6px}
1279
1500
  .cmdk-item{
1280
- width:100%;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;
1501
+ width:100%;display:flex;align-items:center;gap:10px;border:0;border-radius:8px;
1281
1502
  background:transparent;color:var(--fg);font:inherit;font-size:13px;cursor:pointer;
1282
- padding:8px;text-align:left;
1503
+ padding:8px 14px;text-align:left;
1283
1504
  }
1284
1505
  /* Hover and the keyboard cursor are the same highlight: a list driven by both
1285
1506
  at once needs one answer to "which row does Enter take". */
@@ -1290,9 +1511,9 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1290
1511
  font-size:11px;color:var(--dim);flex:0 0 auto;max-width:45%;
1291
1512
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1292
1513
  }
1293
- .cmdk-empty{padding:12px 8px;font-size:12.5px;color:var(--dim)}
1514
+ .cmdk-empty{padding:12px 14px;font-size:12.5px;color:var(--dim)}
1294
1515
  .cmdk-foot{
1295
- display:flex;gap:14px;padding:8px 12px 10px;font-size:11px;color:var(--dim);
1516
+ display:flex;gap:16px;padding:9px 22px 11px;font-size:11px;color:var(--dim);
1296
1517
  box-shadow:0 -1px 0 var(--line);
1297
1518
  }
1298
1519
  .cmdk-foot kbd{
@@ -1456,11 +1677,25 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1456
1677
  .act .ico-sm{width:13px;height:13px;opacity:.9}
1457
1678
  .md{word-break:break-word}
1458
1679
  .md-p{margin:0 0 10px;white-space:pre-wrap}
1459
- .md-h{margin:16px 0 8px;font-size:15px}
1680
+ .md-h{margin:16px 0 8px;font-size:14px}
1681
+ .md-h1{font-size:18px} .md-h2{font-size:16px} .md-h3{font-size:15px}
1682
+ .md>.md-h:first-child{margin-top:0}
1460
1683
  .md-list{margin:0 0 10px;padding-left:22px}
1684
+ .md-list .md-list{margin:2px 0 0}
1685
+ .md-list>li{margin:2px 0}
1686
+ .md-loose>li>.md-p{margin:0 0 6px}
1687
+ .md-list>li.md-task{list-style:none}
1688
+ .md-check{margin:0 7px 0 -20px;vertical-align:-1px}
1461
1689
  .md-quote{margin:0 0 10px;padding-left:12px;border-left:3px solid var(--line);color:var(--dim)}
1462
1690
  .md-code{background:var(--field);border-radius:5px;padding:1.5px 5px;font-size:13px}
1463
1691
  .md-link{color:var(--accent)}
1692
+ .md-dead{text-decoration:underline dotted;text-underline-offset:3px}
1693
+ .md-hr{border:0;height:1px;background:var(--line);margin:14px 0}
1694
+ .md-table-wrap{overflow-x:auto;margin:0 0 12px}
1695
+ .md-table{border-collapse:collapse;font-size:13px}
1696
+ .md-table th,.md-table td{padding:6px 12px;text-align:left;vertical-align:top}
1697
+ .md-table th{font-weight:600;background:var(--field)}
1698
+ .md-table tbody tr:nth-child(even){background:var(--hover)}
1464
1699
  .code-wrap{border-radius:10px;overflow:hidden;margin:0 0 12px;background:var(--panel)}
1465
1700
  .code-bar{
1466
1701
  display:flex;justify-content:space-between;align-items:center;padding:4px 10px;
@@ -1478,7 +1713,7 @@ details.tool>summary{
1478
1713
  font-size:13px;list-style:none;
1479
1714
  }
1480
1715
  details.tool>summary::-webkit-details-marker{display:none}
1481
- .tool .dot{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:0 0 auto}
1716
+ .tool .dot{width:7px;height:7px;border-radius:50%;background:var(--brand);flex:0 0 auto}
1482
1717
  .tool.done .dot{background:var(--ok)}
1483
1718
  .tool.failed .dot{background:var(--err)}
1484
1719
  .tool .name{font-family:ui-monospace,monospace}
@@ -1486,6 +1721,9 @@ details.tool>summary::-webkit-details-marker{display:none}
1486
1721
  .tool-body{padding:2px 12px 10px}
1487
1722
  .tool-body pre{margin:0;overflow-x:auto;font-size:12px;max-height:340px}
1488
1723
  .tool-body .label{font-size:11px;color:var(--dim);margin:0 0 4px}
1724
+ .tool-body .kv{margin:0 0 8px}
1725
+ .tool-summary{font-size:12.5px;line-height:1.5;margin:0 0 6px}
1726
+ details.tool-raw>summary{font-size:11px;color:var(--dim);cursor:pointer;margin:0 0 4px}
1489
1727
  pre.diff{margin:0;font-size:12px;line-height:1.5;overflow-x:auto}
1490
1728
  .d-add{background:var(--add-bg);color:var(--add)}
1491
1729
  .d-del{background:var(--del-bg);color:var(--del)}
@@ -1506,13 +1744,29 @@ details.work[open]>summary::before{transform:rotate(90deg)}
1506
1744
  .working-time{font-size:12px;opacity:.7}
1507
1745
  .spinner{
1508
1746
  width:12px;height:12px;flex:0 0 auto;border-radius:50%;
1509
- border:1.5px solid var(--line);border-top-color:var(--dim);
1747
+ border:1.5px solid var(--line);border-top-color:var(--brand);
1510
1748
  animation:spin .7s linear infinite;
1511
1749
  }
1512
1750
  @keyframes spin{to{transform:rotate(360deg)}}
1751
+ /* A silver band sweeps the brand-coloured label while the model is still
1752
+ thinking; the reasoning block stops, and falls back to --dim, once
1753
+ sealThinking drops its spinner. */
1754
+ .working-label,
1755
+ details.thinking>summary:has(.spinner) .t-label{
1756
+ background:linear-gradient(90deg,var(--brand-ink) 0%,var(--brand-ink) 42%,var(--brand-shine) 50%,var(--brand-ink) 58%,var(--brand-ink) 100%);
1757
+ background-size:250% 100%;
1758
+ -webkit-background-clip:text;background-clip:text;
1759
+ -webkit-text-fill-color:transparent;color:transparent;
1760
+ animation:shimmer 1.8s linear infinite;
1761
+ }
1762
+ @keyframes shimmer{from{background-position:100% 0}to{background-position:0 0}}
1513
1763
  @media (prefers-reduced-motion:reduce){
1514
1764
  /* A spinner is decoration; the elapsed seconds carry the same information. */
1515
1765
  .spinner{animation:none;border-top-color:var(--line)}
1766
+ .working-label,
1767
+ details.thinking>summary:has(.spinner) .t-label{
1768
+ animation:none;background:none;-webkit-text-fill-color:currentColor;color:var(--brand-ink);
1769
+ }
1516
1770
  }
1517
1771
 
1518
1772
  /* reasoning */
@@ -1523,6 +1777,13 @@ details.thinking>summary{
1523
1777
  }
1524
1778
  details.thinking>summary::-webkit-details-marker{display:none}
1525
1779
  details.thinking>summary:hover{color:var(--fg)}
1780
+ /* A chevron after the label says the block opens; it turns down when open. */
1781
+ details.thinking>summary::after{
1782
+ content:"";width:5px;height:5px;flex:0 0 auto;margin-left:2px;
1783
+ border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
1784
+ transform:rotate(-45deg);transition:transform .15s;
1785
+ }
1786
+ details.thinking[open]>summary::after{transform:translateY(-2px) rotate(45deg)}
1526
1787
  .thinking-body{
1527
1788
  white-space:pre-wrap;color:var(--dim);font-size:13.5px;line-height:1.6;
1528
1789
  margin-top:8px;padding-left:12px;border-left:2px solid var(--line);
@@ -1562,8 +1823,8 @@ textarea::placeholder{color:var(--dim)}
1562
1823
  the field only has to agree on where each glyph lands, not on what is
1563
1824
  drawn there. The selection is translucent so the words under it stay
1564
1825
  readable; an opaque highlight would sit on top of the mirror. */
1565
- .input-wrap textarea{color:transparent;caret-color:var(--fg)}
1566
- .input-wrap textarea::selection{background:#3b82f640}
1826
+ .input-wrap textarea{color:transparent;caret-color:var(--brand)}
1827
+ .input-wrap textarea::selection{background:var(--brand-tint)}
1567
1828
  /* Every metric that decides where a glyph lands has to match the field: font,
1568
1829
  line-height, wrapping, and the padding, which is zero on this textarea.
1569
1830
  Six pixels wider on each side with the text pushed back in by six: the
@@ -1608,11 +1869,11 @@ textarea::placeholder{color:var(--dim)}
1608
1869
  where a glyph lands has to match: font, line-height, wrapping, and the
1609
1870
  padding, which is zero on the composer's textarea and must be zero here. */
1610
1871
  textarea.boxed{
1611
- border:1px solid var(--line);background:var(--field);border-radius:6px;
1872
+ border:0;background:var(--field);border-radius:6px;
1612
1873
  padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
1613
1874
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
1614
1875
  }
1615
- textarea.boxed:focus{border-color:var(--dim)}
1876
+ textarea.boxed:focus{outline:none;background:var(--field-focus)}
1616
1877
  .composer-bar{display:flex;align-items:center;gap:6px;margin-top:10px}
1617
1878
  .composer.drag{background:var(--field-focus);outline:2px dashed var(--line);outline-offset:-6px}
1618
1879
  .icon-btn{
@@ -1625,6 +1886,41 @@ textarea.boxed:focus{border-color:var(--dim)}
1625
1886
  settings on one bar, none of them shouting over the others. */
1626
1887
  #workspace-picker .picker-btn{gap:6px}
1627
1888
  #workspace-picker .ico-sm{width:13px;height:13px;opacity:1;flex:0 0 auto}
1889
+ /*
1890
+ * Reasoning effort. A setting of its own beside the model rather than rows in
1891
+ * the model list: the two are picked independently, and folding tiers into
1892
+ * the list would multiply every model by six.
1893
+ *
1894
+ * The panel is a rail of stops read left to right, faster to smarter, because
1895
+ * the tiers are one quantity and a list would present them as unrelated
1896
+ * options. Stops come from the model: a tier it lacks is not drawn at all.
1897
+ */
1898
+ #effort-picker .picker-btn{gap:5px}
1899
+ #effort-picker .picker-btn .ico-sm{width:13px;height:13px;opacity:1}
1900
+ #effort-picker .menu{min-width:272px;width:272px;padding:14px 14px 12px;left:auto;right:0}
1901
+ .effort-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
1902
+ .effort-title{font-size:13px;color:var(--dim)}
1903
+ .effort-current{font-size:13px;font-weight:600;color:var(--fg)}
1904
+ .effort-desc{margin-top:3px;font-size:12px;line-height:1.4;color:var(--dim);min-height:34px}
1905
+ .effort-ends{display:flex;justify-content:space-between;margin:10px 0 6px;font-size:11.5px;color:var(--dim)}
1906
+ .effort-rail{
1907
+ --stop:26px;position:relative;display:flex;align-items:center;justify-content:space-between;
1908
+ height:26px;border-radius:13px;background:var(--hover);
1909
+ }
1910
+ /* Filled up to the centre of the chosen stop, so the amount reads at a glance. */
1911
+ .effort-fill{position:absolute;left:0;top:0;bottom:0;border-radius:13px;background:var(--line);transition:width .15s}
1912
+ .effort-stop{
1913
+ position:relative;z-index:1;width:26px;height:26px;padding:0;border:0;outline:0;border-radius:50%;
1914
+ background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;
1915
+ }
1916
+ .effort-stop:focus{outline:0}
1917
+ .effort-stop:focus-visible .effort-dot{background:var(--fg)}
1918
+ .effort-stop:hover .effort-dot{background:var(--fg);opacity:.7}
1919
+ .effort-dot{width:6px;height:6px;border-radius:50%;background:var(--dim);opacity:.6}
1920
+ .effort-knob{width:18px;height:18px;border-radius:50%;background:var(--fg)}
1921
+ .effort-names{display:flex;justify-content:space-between;margin-top:6px}
1922
+ .effort-name{width:26px;display:flex;justify-content:center;font-size:11px;color:var(--dim);white-space:nowrap}
1923
+ .effort-name.on{color:var(--fg);font-weight:600}
1628
1924
  #attachments{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
1629
1925
  #attachments:empty{display:none}
1630
1926
  .att{
@@ -1694,6 +1990,13 @@ textarea.boxed:focus{border-color:var(--dim)}
1694
1990
  the arrow keys. A ring around a popup that was just clicked open says
1695
1991
  nothing the row highlight is not already saying. */
1696
1992
  .menu:focus{outline:none}
1993
+ /* No ring and no border marks a focused or selected thing anywhere in the app.
1994
+ That includes the browser's own focus outline, which drew a blue ring round
1995
+ the account button while its menu was open. Keyboard focus is carried by a
1996
+ fill instead; the zero-specificity selector leaves every control that
1997
+ already paints its own background alone. */
1998
+ :focus,:focus-visible{outline:none}
1999
+ :where(button,a,summary,[role="button"],[tabindex]):focus-visible{background-color:var(--hover)}
1697
2000
  /* The triangle the group headers use, turned on a row that leads somewhere:
1698
2001
  the + opens onto four offers, and three of them go one screen deeper. */
1699
2002
  .menu-item .chev{
@@ -1733,36 +2036,48 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1733
2036
  * happen to which file. The parameters used to be dumped as JSON, which put
1734
2037
  * escaped newlines and the tool's own field names in front of the one thing
1735
2038
  * worth reading — so the body renders the payload instead, and the plumbing
1736
- * moved behind a toggle. */
1737
- #ask{width:min(620px,94vw);max-width:min(620px,94vw)}
1738
- .ask-head{padding:16px 18px 12px;border-bottom:1px solid var(--line)}
1739
- .ask-tags{display:flex;align-items:center;gap:8px;margin-bottom:7px}
2039
+ * moved behind a toggle.
2040
+ *
2041
+ * Three parts and no rules between them: what (head), the payload (one filled
2042
+ * block, aligned with the head), and the answer. Hairlines between four
2043
+ * sections made the middle read as leftovers from different components. */
2044
+ #ask{width:min(620px,94vw);max-width:min(620px,94vw);padding-bottom:10px}
2045
+ .ask-head{padding:18px 20px 12px;display:flex;flex-direction:column;gap:6px;min-width:0}
1740
2046
  .ask-tag{
1741
- font-size:11.5px;line-height:1.5;padding:1px 8px;border-radius:999px;
2047
+ align-self:flex-start;font-size:11.5px;line-height:1.7;padding:0 9px;border-radius:999px;
1742
2048
  background:var(--field);color:var(--dim);
1743
2049
  }
1744
- .ask-tool{font-size:11.5px;color:var(--dim)}
1745
- .ask-target{
2050
+ .ask-title{display:flex;align-items:center;gap:10px;min-width:0}
2051
+ .ask-logo{width:22px;height:22px;border-radius:6px;object-fit:contain;flex:none}
2052
+ .ask-target{font-size:15px;line-height:1.45;font-weight:600;overflow-wrap:anywhere;min-width:0}
2053
+ .ask-target.mono{
1746
2054
  font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
1747
- font-size:13.5px;line-height:1.5;word-break:break-all;white-space:pre-wrap;
2055
+ font-size:13.5px;font-weight:500;white-space:pre-wrap;
1748
2056
  }
1749
- .ask-meta{font-size:12px;color:var(--dim);margin-top:3px;word-break:break-all}
1750
- .ask-body{max-height:260px;overflow:auto;padding:8px 0}
2057
+ .ask-target .dir{color:var(--dim);font-weight:400}
2058
+ .ask-meta{font-size:12px;line-height:1.5;color:var(--dim);overflow-wrap:anywhere}
2059
+ .ask-body{margin:0 20px;border-radius:10px;background:var(--field);max-height:280px;overflow:auto;padding:8px 0}
1751
2060
  .ask-body.clamped{max-height:none;overflow:hidden}
1752
2061
  .ask-line{
1753
2062
  display:flex;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
1754
2063
  font-size:12.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;
1755
2064
  }
1756
2065
  .ask-line .n{
1757
- flex:0 0 40px;text-align:right;padding-right:10px;color:var(--dim);
2066
+ flex:0 0 36px;text-align:right;padding-right:12px;color:var(--dim);
1758
2067
  opacity:.65;user-select:none;
1759
2068
  }
2069
+ .ask-body.diff .ask-line .n{flex-basis:28px;text-align:center;padding-right:4px}
1760
2070
  .ask-line .s{flex:1;padding-right:14px}
1761
2071
  .ask-line.add{background:var(--add-bg)}
1762
2072
  .ask-line.add .s{color:var(--add)}
1763
2073
  .ask-line.del{background:var(--del-bg)}
1764
2074
  .ask-line.del .s{color:var(--del)}
1765
- .ask-more{display:flex;gap:6px;padding:2px 18px 12px;border-bottom:1px solid var(--line)}
2075
+ .ask-body .ask-code{
2076
+ margin:0;padding:2px 14px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
2077
+ font-size:12.5px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;
2078
+ }
2079
+ .ask-body .kv{padding:4px 14px}
2080
+ .ask-more{display:flex;gap:4px;padding:6px 14px 0}
1766
2081
  .ask-more button{
1767
2082
  border:0;border-radius:7px;background:transparent;color:var(--dim);
1768
2083
  font:inherit;font-size:12px;padding:4px 8px;cursor:pointer;
@@ -1771,7 +2086,7 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1771
2086
  /* The three allow options are one decision at three scopes, not three
1772
2087
  * decisions, so they sit on one row as peers. Splitting them across two rows
1773
2088
  * under a "remember the answer" heading asked the same question twice. */
1774
- .dlg-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:14px 18px 4px}
2089
+ .dlg-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:16px 20px 4px}
1775
2090
  /* Holds the way out apart from the answers without a spacer element: a growing
1776
2091
  * spacer claims width of its own, which forced the row to wrap one button early
1777
2092
  * on a narrow window. The quiet button leads its row, so the gap goes after it. */
@@ -1798,21 +2113,161 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1798
2113
  .dlg-actions button.quiet{background:transparent;color:var(--dim)}
1799
2114
  .dlg-actions button.quiet:hover{background:var(--hover);color:var(--fg)}
1800
2115
  .dlg-actions kbd{
1801
- font:inherit;font-size:11px;opacity:.6;border:1px solid currentColor;border-radius:4px;
1802
- padding:0 4px;line-height:1.5;
2116
+ font:inherit;font-size:11px;opacity:.7;background:var(--field);border-radius:4px;
2117
+ padding:0 5px;line-height:1.6;
2118
+ }
2119
+ .dlg-actions button.approve kbd{background:#ffffff26}
2120
+ /* Only while a "remember" answer is focused, and taking no room otherwise: a
2121
+ * reserved empty line under the buttons read as the dialog being unfinished. */
2122
+ .ask-scope{padding:8px 20px 4px;font-size:11.5px;line-height:1.6;color:var(--dim)}
2123
+
2124
+ /* Parameters as a reader wants them: names on the left, values as values —
2125
+ * text with its line breaks, lists as chips — instead of JSON with line numbers. */
2126
+ .kv{display:grid;grid-template-columns:minmax(64px,max-content) minmax(0,1fr);column-gap:14px;row-gap:6px;font-size:12.5px;line-height:1.55}
2127
+ .kv-k{color:var(--dim);font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;overflow-wrap:anywhere}
2128
+ .kv-v{min-width:0;overflow-wrap:anywhere;white-space:pre-wrap}
2129
+ .kv-v.empty{color:var(--dim)}
2130
+ .kv-chip{display:inline-block;margin:0 4px 2px 0;padding:0 8px;border-radius:999px;background:var(--sel);font-size:12px;white-space:nowrap}
2131
+ .kv-v pre{margin:0;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}
2132
+
2133
+ /*
2134
+ * Phone-only chrome, declared before the breakpoint that shows it: a base
2135
+ * rule written after the media query wins the cascade, which is how the
2136
+ * burger spent a release at display:none on every width.
2137
+ */
2138
+ #burger{
2139
+ display:none;width:36px;height:36px;margin-left:-8px;border:0;border-radius:8px;
2140
+ background:none;color:var(--fg);font:inherit;font-size:18px;cursor:pointer;place-items:center;
1803
2141
  }
1804
- .ask-scope{
1805
- padding:8px 18px 16px;font-size:11.5px;line-height:1.6;color:var(--dim);min-height:34px;
2142
+ #burger:hover{background:var(--hover)}
2143
+ #nav-close{
2144
+ display:none;width:32px;height:32px;margin-left:auto;border:0;border-radius:8px;
2145
+ background:transparent;color:var(--dim);cursor:pointer;padding:0;place-items:center;
1806
2146
  }
2147
+ #nav-close svg{width:14px;height:14px}
2148
+ #nav-close:hover{background:var(--hover);color:var(--fg)}
2149
+ #scrim{display:none;position:fixed;inset:0;z-index:19;background:#00000059}
2150
+ #review-close{
2151
+ display:none;width:32px;height:32px;border:0;border-radius:8px;background:transparent;
2152
+ color:var(--fg);cursor:pointer;padding:0;place-items:center;margin-left:8px;
2153
+ }
2154
+ #review-close svg{width:16px;height:16px}
1807
2155
 
2156
+ /*
2157
+ * A phone.
2158
+ *
2159
+ * The desktop layout is three columns that share one window; a phone has room
2160
+ * for one of them at a time. The sidebar becomes a drawer over the transcript,
2161
+ * the review and artifact panes take the whole screen while they are open,
2162
+ * and the composer's bar is split in two: where the session runs and what it
2163
+ * may do sit above the text, the model and the send button below it.
2164
+ */
1808
2165
  @media (max-width:760px){
1809
- aside{position:fixed;inset:0 auto 0 0;z-index:20;transform:translateX(-100%);transition:transform .2s}
1810
- aside.open{transform:none}
1811
- #burger{display:block}
1812
- /* The burger lives here, so the bar has a reason to exist again. */
1813
- #top{padding:10px 18px}
2166
+ body>aside{
2167
+ position:fixed;inset:0 auto 0 0;z-index:20;width:min(280px,84vw);flex-basis:auto;
2168
+ transform:translateX(-100%);transition:transform .2s;
2169
+ padding-bottom:env(safe-area-inset-bottom);
2170
+ }
2171
+ body>aside.open{transform:none;box-shadow:0 0 0 1px #0000000a,0 12px 40px #00000040}
2172
+ body.nav-open #scrim{display:block}
2173
+ #nav-close{display:grid}
2174
+ #burger{display:grid}
2175
+ #top{padding:6px 10px}
2176
+ #top:has(#changes-btn:not([hidden])){padding:6px 10px}
2177
+
2178
+ /* Both panes are the screen while open, and each gets a way out of its own:
2179
+ the top bar with the changes button is underneath them. */
2180
+ #review.open,#shelf.open{
2181
+ position:fixed;inset:0;z-index:50;width:100%;height:100%;flex:none;transition:none;
2182
+ }
2183
+ #review-head{padding:10px 10px 8px 16px}
2184
+ #review-close{display:grid}
2185
+ #files{max-height:40%}
2186
+ #shelf-bar{flex-wrap:wrap;row-gap:4px}
2187
+
2188
+ #log{padding:16px 14px 8px}
2189
+ #panel-view{padding:20px 14px 24px}
2190
+ .empty-brand{gap:12px;margin-bottom:22px}
2191
+ .brand-mark,.brand-mark svg{width:48px;height:48px}
2192
+ .brand-name{font-size:30px}
2193
+ #empty{margin-top:4vh}
2194
+
2195
+ /* The composer. The bar's children become items of the box itself so they
2196
+ can be dealt into two rows around the field; the bar element stays in the
2197
+ markup for the desktop layout and the scripts that hang menus off it. */
2198
+ form{padding:6px 12px calc(12px + env(safe-area-inset-bottom))}
2199
+ .composer{
2200
+ position:relative;display:flex;flex-wrap:wrap;align-items:center;
2201
+ column-gap:6px;row-gap:8px;padding:10px 12px 10px;
2202
+ }
2203
+ .composer-bar{display:contents}
2204
+ #attachments{order:-3;flex:0 0 100%;margin-bottom:0}
2205
+ #workspace-picker,#mode-picker{order:-2}
2206
+ .input-wrap{order:-1;flex:0 0 100%}
2207
+ .composer .picker{position:static;min-width:0}
2208
+ .composer .picker-btn{max-width:100%;height:32px}
2209
+ #send,#stop{flex:0 0 auto}
2210
+ textarea{max-height:32dvh}
2211
+ /* Menus span the box rather than hang off one picker: from a picker on the
2212
+ right they otherwise open past the edge, where overflow:hidden loses them. */
2213
+ .composer .menu,#attach-picker .menu{
2214
+ left:0;right:0;width:auto;min-width:0;max-width:none;bottom:calc(100% + 8px);
2215
+ }
2216
+ .menu-body{max-height:min(50dvh,340px)}
2217
+ #effort-picker .menu{left:0;width:auto;padding:16px 16px 14px}
2218
+ .effort-rail{--stop:40px;height:40px;border-radius:20px}
2219
+ .effort-fill{border-radius:20px}
2220
+ .effort-stop,.effort-name{width:40px}
2221
+ .effort-stop{height:40px}
2222
+ .effort-knob{width:28px;height:28px}
2223
+ #jump{right:14px;bottom:160px}
2224
+
2225
+ /* Dialogs, with the keyboard up: vh is the height without it. */
2226
+ #market{max-height:82dvh}
2227
+ #provider-picker,#browse{max-height:76dvh}
2228
+ #project-picker{max-height:70dvh}
2229
+ .mk-grid{grid-template-columns:minmax(0,1fr)}
2230
+ .mk-search{padding-right:44px}
2231
+ .ask-body{max-height:min(260px,40dvh)}
2232
+ .ask-line .n{flex:0 0 28px;padding-right:6px}
2233
+ .ask-head{padding:16px 16px 10px}
2234
+ .ask-body{margin:0 16px}
2235
+ .kv{grid-template-columns:minmax(0,1fr);row-gap:2px}
2236
+ .kv-v{margin-bottom:6px}
2237
+ }
2238
+
2239
+ /*
2240
+ * A finger, not a pointer.
2241
+ *
2242
+ * Hover reveals nothing on a touch screen, so what the desktop shows on hover
2243
+ * is shown outright; and a field under 16px makes iOS zoom the page when it
2244
+ * is focused, so every field is 16px here whatever it is elsewhere.
2245
+ */
2246
+ @media (hover:none) and (pointer:coarse){
2247
+ .session .more,.fold-row .more{display:grid;width:28px;height:28px}
2248
+ .proj-forget{opacity:1}
2249
+ .actions{position:static;opacity:1;margin-top:4px}
2250
+ .turn{margin-bottom:14px}
2251
+ .act{padding:6px 10px}
2252
+ .tab .x{opacity:.55;width:24px;height:24px}
2253
+ #tab-add{width:32px;height:32px}
2254
+ .tab{height:34px}
2255
+ .nav-item,.session,.fold,.project{height:38px}
2256
+ .show-more{height:32px}
2257
+ .icon-btn{width:34px;height:34px}
2258
+ button.primary{width:36px;height:36px}
2259
+ button.ghost,a.ghost{height:36px}
2260
+ .att .x{padding:4px 6px}
2261
+ .cn-x,.mk-close,.theme-btn{width:36px;height:36px}
2262
+ .cn-why{width:28px;height:28px}
2263
+ .switch{width:40px;height:24px}
2264
+ .dlg-actions button{padding:10px 14px}
2265
+ input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),select,textarea,
2266
+ .menu-search input,.mk-search,.art-search,.keyrow input,.cn-body input,.pp-head input,
2267
+ textarea.boxed{font-size:16px}
2268
+ /* The chip layer under the field has to keep the field's metrics. */
2269
+ #input-mirror{font-size:16px}
1814
2270
  }
1815
- #burger{display:none;background:none;border:0;color:var(--fg);font:inherit;cursor:pointer;font-size:18px}
1816
2271
  `;
1817
2272
  const APP_SCRIPT = String.raw `
1818
2273
  const STRINGS = window.__STRINGS;
@@ -1838,6 +2293,10 @@ if (window.oharness && window.oharness.insetTitlebar) {
1838
2293
  /** Whether this page is the desktop window rather than a browser tab. */
1839
2294
  const IS_DESKTOP = Boolean(window.oharness && window.oharness.desktop);
1840
2295
 
2296
+ /** Whether the page is laid out for a phone right now; the stylesheet's breakpoint. */
2297
+ const PHONE_QUERY = window.matchMedia("(max-width:760px)");
2298
+ const isPhone = () => PHONE_QUERY.matches;
2299
+
1841
2300
  // --- language --------------------------------------------------------------
1842
2301
 
1843
2302
  /**
@@ -1877,6 +2336,9 @@ const ICON_PATHS = {
1877
2336
  // both menus open upward, and both mark themselves with a caret that reads as
1878
2337
  // "there is a list here" rather than one that promises a direction.
1879
2338
  caret: ["M4.5 6.5 8 10l3.5-3.5"],
2339
+ // The account control can both open and close its menu, so it uses the
2340
+ // familiar paired chevrons instead of borrowing a one-way dropdown caret.
2341
+ chevronsVertical: ["M5.5 6.5 8 4l2.5 2.5", "M5.5 9.5 8 12l2.5-2.5"],
1880
2342
  // The three themes, drawn the same way the site draws them so the two
1881
2343
  // controls are recognisably the same control.
1882
2344
  themeSystem: ["M6 13.4h4"],
@@ -1944,6 +2406,37 @@ const ICON_PATHS = {
1944
2406
  "M14 10a1.33 1.33 0 0 1-1.33 1.33H4.67L2 14V3.33A1.33 1.33 0 0 1 3.33 2h9.34" +
1945
2407
  "A1.33 1.33 0 0 1 14 3.33Z",
1946
2408
  ],
2409
+ // The five permission modes. Five short words in a column look alike until
2410
+ // they are read; a glyph in front of each lets the one in use be found by
2411
+ // shape from the bar, the way the vendor mark does for the model.
2412
+ //
2413
+ // Manual is by hand, and a raised palm is also the sign for "wait": three
2414
+ // fingers, a thumb, and the heel of the hand in one sweep. Four fingers
2415
+ // would be the honest count, but at fifteen pixels the fourth turns the
2416
+ // top edge into a comb.
2417
+ modeAsk: [
2418
+ "M12 7.4V4a1.33 1.33 0 0 0-2.67 0",
2419
+ "M9.33 6.7V2.7a1.33 1.33 0 0 0-2.66 0v1.3",
2420
+ "M6.67 7V4A1.33 1.33 0 0 0 4 4v5.33",
2421
+ "M12 5.33a1.33 1.33 0 1 1 2.67 0v4a5.33 5.33 0 0 1-5.34 5.34H8" +
2422
+ "c-1.87 0-3-.58-4-1.56l-2.4-2.4a1.33 1.33 0 0 1 1.89-1.89L4.67 10",
2423
+ ],
2424
+ // Accept edits lets the pencil through.
2425
+ modeEdits: ["M11.3 2.7 13.3 4.7 5.3 12.7H3.3v-2Z", "M9.8 4.2l2 2"],
2426
+ // Plan is read-only: an eye, which looks and does not touch.
2427
+ modePlan: ["M1.6 8s2.4-4.4 6.4-4.4S14.4 8 14.4 8s-2.4 4.4-6.4 4.4S1.6 8 1.6 8Z"],
2428
+ modePlanCircles: [[8, 8, 2.1]],
2429
+ // Auto waves the routine through: a bolt.
2430
+ modeAuto: ["M8.8 1.6 3.6 9h4l-.7 5.4L12.4 7h-4Z"],
2431
+ // Bypass leaves the gate open: a padlock with its shackle swung clear.
2432
+ modeBypass: ["M5.4 7.4V5.2a2.6 2.6 0 0 1 5-1"],
2433
+ modeBypassRects: [[3.2, 7.4, 9.6, 6.2, 1.6]],
2434
+ // The date picker's trigger and its month arrows. The same marks the site's
2435
+ // picker draws, because it is the same control.
2436
+ calendar: ["M2 6.5h12M5.5 1.5v3M10.5 1.5v3"],
2437
+ calendarRects: [[2, 3, 12, 11, 2.5]],
2438
+ chevronLeft: ["M10 3.5 5.5 8l4.5 4.5"],
2439
+ chevronRight: ["M6 3.5 10.5 8 6 12.5"],
1947
2440
  };
1948
2441
 
1949
2442
  function svgIcon(name, extra) {
@@ -2064,6 +2557,25 @@ async function getJson(path) {
2064
2557
  }
2065
2558
 
2066
2559
  /** A banner for a panel that could not load, in place of blank fields. */
2560
+ /**
2561
+ * A memory error in this page's language. The engine sends a code and its
2562
+ * values with the English message; the page says it in its own words when it
2563
+ * has them, and passes the message through when it does not.
2564
+ */
2565
+ function memoryText(failure) {
2566
+ const key = failure && failure.code ? "memory.err." + failure.code : "";
2567
+ if (key && (STRINGS.en[key] || (STRINGS[LOCALE] && STRINGS[LOCALE][key]))) return t(key, failure.params || {});
2568
+ return (failure && failure.message) || t("memory.failed");
2569
+ }
2570
+
2571
+ /** Stands in while a panel fetches, so a click never lands on a blank page. */
2572
+ function panelLoading() {
2573
+ const panel = el("div", "panel panel-loading");
2574
+ panel.appendChild(el("span", "spinner"));
2575
+ panel.appendChild(el("span", "working-label", t("market.loading")));
2576
+ return panel;
2577
+ }
2578
+
2067
2579
  function panelError(message) {
2068
2580
  const panel = el("div", "panel");
2069
2581
  const box = el("div", "row-card");
@@ -2468,12 +2980,16 @@ function makePicker(id, onPick, extra) {
2468
2980
  return api;
2469
2981
  }
2470
2982
 
2983
+ // Drawn glyphs rather than vendor tiles: a mode is chrome, not a party being
2984
+ // paid, so it gets the bare stroke every other menu icon has.
2985
+ const modeMark = (icon) => (extra) => svgIcon(icon, "mode-mark" + (extra ? " " + extra : ""));
2986
+
2471
2987
  const MODES = [
2472
- { value: "default", label: "Manual", sub: "ask first" },
2473
- { value: "acceptEdits", label: "Accept edits", sub: "edits pass" },
2474
- { value: "plan", label: "Plan", sub: "read-only" },
2475
- { value: "auto", label: "Auto", sub: "routine passes" },
2476
- { value: "bypassPermissions", label: "Bypass", sub: "all pass" },
2988
+ { value: "default", label: "Manual", sub: "ask first", mark: modeMark("modeAsk") },
2989
+ { value: "acceptEdits", label: "Accept edits", sub: "edits pass", mark: modeMark("modeEdits") },
2990
+ { value: "plan", label: "Plan", sub: "read-only", mark: modeMark("modePlan") },
2991
+ { value: "auto", label: "Auto", sub: "routine passes", mark: modeMark("modeAuto") },
2992
+ { value: "bypassPermissions", label: "Bypass", sub: "all pass", mark: modeMark("modeBypass") },
2477
2993
  ];
2478
2994
 
2479
2995
  const modelPicker = makePicker(
@@ -2522,6 +3038,139 @@ const modelPicker = makePicker(
2522
3038
  );
2523
3039
  const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
2524
3040
 
3041
+ /**
3042
+ * Reasoning effort for the session on screen.
3043
+ *
3044
+ * Not built on makePicker: that draws a searchable list, and effort is one
3045
+ * quantity on a scale. The stops are whatever the current model accepts, plus
3046
+ * "off" where the model can be asked not to think, so a tier the model would
3047
+ * clamp away is never offered in the first place.
3048
+ *
3049
+ * What the server reports is the tier the next request will carry. A pick is
3050
+ * kept as picked on the session, so moving to a model without "max" shows the
3051
+ * tier it lands on, and moving back shows "max" again.
3052
+ */
3053
+ const effortPicker = (() => {
3054
+ const root = document.getElementById("effort-picker");
3055
+ const button = root.querySelector(".picker-btn");
3056
+ const label = root.querySelector(".picker-value");
3057
+ const menu = root.querySelector(".menu");
3058
+ let stops = [];
3059
+ let value = null;
3060
+
3061
+ const name = (stop) => t("effort." + stop);
3062
+ const close = () => {
3063
+ root.classList.remove("open");
3064
+ menu.hidden = true;
3065
+ };
3066
+
3067
+ const pick = (next) => {
3068
+ if (!next || next === value) return;
3069
+ value = next;
3070
+ paint(true);
3071
+ track("effort_changed", { effort: next });
3072
+ void api("/effort", { session: activeId, effort: next }).then(applyState);
3073
+ };
3074
+
3075
+ function paint(refocus) {
3076
+ const index = Math.max(0, stops.indexOf(value));
3077
+ label.textContent = value ? t("effort.short." + value) : "…";
3078
+ button.title = t("effort.tooltip") + ": " + (value ? name(value) : "");
3079
+ if (menu.hidden && !refocus) return;
3080
+
3081
+ menu.replaceChildren();
3082
+ const head = el("div", "effort-head");
3083
+ head.appendChild(el("span", "effort-title", t("effort.title")));
3084
+ head.appendChild(el("span", "effort-current", value ? name(value) : ""));
3085
+ menu.appendChild(head);
3086
+ menu.appendChild(el("div", "effort-desc", value ? t("effort.desc." + value) : ""));
3087
+
3088
+ const ends = el("div", "effort-ends");
3089
+ ends.appendChild(el("span", null, t("effort.faster")));
3090
+ ends.appendChild(el("span", null, t("effort.smarter")));
3091
+ menu.appendChild(ends);
3092
+
3093
+ const rail = el("div", "effort-rail");
3094
+ rail.setAttribute("role", "radiogroup");
3095
+ rail.setAttribute("aria-label", t("effort.tooltip"));
3096
+ const fill = el("span", "effort-fill");
3097
+ const share = stops.length > 1 ? index / (stops.length - 1) : 0;
3098
+ // From the rail's left edge to the far side of the chosen stop.
3099
+ fill.style.width = "calc((100% - var(--stop)) * " + share + " + var(--stop))";
3100
+ rail.appendChild(fill);
3101
+
3102
+ const names = el("div", "effort-names");
3103
+ stops.forEach((stop, i) => {
3104
+ const on = i === index;
3105
+ const dot = el("button", "effort-stop" + (on ? " on" : ""));
3106
+ dot.type = "button";
3107
+ dot.setAttribute("role", "radio");
3108
+ dot.setAttribute("aria-checked", on ? "true" : "false");
3109
+ dot.setAttribute("aria-label", name(stop));
3110
+ dot.tabIndex = on ? 0 : -1;
3111
+ dot.title = name(stop);
3112
+ dot.appendChild(el("span", on ? "effort-knob" : "effort-dot"));
3113
+ dot.addEventListener("click", () => pick(stop));
3114
+ dot.addEventListener("keydown", (e) => {
3115
+ const step = e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : 0;
3116
+ if (!step) return;
3117
+ e.preventDefault();
3118
+ pick(stops[Math.min(stops.length - 1, Math.max(0, index + step))]);
3119
+ });
3120
+ rail.appendChild(dot);
3121
+ names.appendChild(el("span", "effort-name" + (on ? " on" : ""), t("effort.short." + stop)));
3122
+ });
3123
+ menu.appendChild(rail);
3124
+ menu.appendChild(names);
3125
+
3126
+ if (refocus) {
3127
+ const active = rail.querySelector(".effort-stop.on");
3128
+ if (active) active.focus();
3129
+ }
3130
+ }
3131
+
3132
+ const open = () => {
3133
+ for (const other of document.querySelectorAll(".picker.open")) {
3134
+ if (other !== root) {
3135
+ other.classList.remove("open");
3136
+ other.querySelector(".menu").hidden = true;
3137
+ }
3138
+ }
3139
+ root.classList.add("open");
3140
+ menu.hidden = false;
3141
+ paint(true);
3142
+ };
3143
+
3144
+ button.addEventListener("click", (e) => {
3145
+ e.stopPropagation();
3146
+ menu.hidden ? open() : close();
3147
+ });
3148
+ menu.addEventListener("click", (e) => e.stopPropagation());
3149
+ document.addEventListener("click", close);
3150
+ document.addEventListener("keydown", (e) => {
3151
+ if (e.key === "Escape" && !menu.hidden) {
3152
+ close();
3153
+ button.focus();
3154
+ }
3155
+ });
3156
+
3157
+ return {
3158
+ set(state) {
3159
+ const levels = Array.isArray(state.effortLevels) ? state.effortLevels : [];
3160
+ // A model with no tiers has nothing to set; hiding beats a control that
3161
+ // does nothing.
3162
+ root.hidden = levels.length === 0 || !state.effort;
3163
+ if (root.hidden) {
3164
+ close();
3165
+ return;
3166
+ }
3167
+ stops = (state.effortOff ? ["off"] : []).concat(levels);
3168
+ value = state.effort;
3169
+ paint(false);
3170
+ },
3171
+ };
3172
+ })();
3173
+
2525
3174
  /**
2526
3175
  * Which tree the session about to start will run in.
2527
3176
  *
@@ -2802,19 +3451,149 @@ function bumpWorkLabel(group) {
2802
3451
  count === 1 ? "1 step" : count + " steps";
2803
3452
  }
2804
3453
 
3454
+ // --- tool presentation -----------------------------------------------------
3455
+ // Shared by the tool cards and the approval dialog, so a call reads the same
3456
+ // whether it is waiting on an answer or already done.
3457
+
3458
+ /** Display names and marks for Cloud apps, learned from catalog answers and connection cards. */
3459
+ const cloudApps = new Map();
3460
+
3461
+ function learnCloudApp(slug, label, logo) {
3462
+ if (typeof slug !== "string" || !slug) return;
3463
+ const known = cloudApps.get(slug) || {};
3464
+ cloudApps.set(slug, {
3465
+ label: typeof label === "string" && label ? label : known.label,
3466
+ logo: typeof logo === "string" && logo ? logo : known.logo,
3467
+ });
3468
+ }
3469
+
3470
+ function cloudAppName(slug) {
3471
+ const known = cloudApps.get(slug);
3472
+ if (known && known.label) return known.label;
3473
+ const words = String(slug || "").replace(/_/g, " ");
3474
+ return words.charAt(0).toUpperCase() + words.slice(1);
3475
+ }
3476
+
3477
+ /** The app's mark, only when it is https — see connectionCard for why. */
3478
+ function cloudAppLogo(slug) {
3479
+ const known = cloudApps.get(slug);
3480
+ if (!known || !known.logo) return undefined;
3481
+ try {
3482
+ const parsed = new URL(known.logo);
3483
+ return parsed.protocol === "https:" ? parsed.href : undefined;
3484
+ } catch {
3485
+ return undefined;
3486
+ }
3487
+ }
3488
+
3489
+ function isEmptyInput(value) {
3490
+ return value === undefined || value === null ||
3491
+ (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
3492
+ }
3493
+
3494
+ /** mcp__github__create_issue reads as "github · create_issue". */
3495
+ function friendlyToolName(name) {
3496
+ const match = /^mcp__(.+?)__(.+)$/.exec(name || "");
3497
+ return match ? match[1] + " · " + match[2] : name || "";
3498
+ }
3499
+
3500
+ /**
3501
+ * A call's one-line name, worked out here rather than taken from the engine:
3502
+ * the engine's titles are English, and a replayed transcript only carries the
3503
+ * tool's name — which is how a reload used to turn "Gmail · send_email" back
3504
+ * into cloud_app_execute.
3505
+ */
3506
+ function toolTitle(name, input, fallback) {
3507
+ const args = input && typeof input === "object" ? input : {};
3508
+ if (name === "cloud_app_tools") {
3509
+ return typeof args.app === "string" ? t("tool.cloudAppTools", { app: cloudAppName(args.app) }) : t("tool.cloudApps");
3510
+ }
3511
+ if (name === "cloud_app_execute" && typeof args.app === "string") {
3512
+ return cloudAppName(args.app) + (typeof args.tool === "string" ? " · " + args.tool : "");
3513
+ }
3514
+ if (name === "ask_user" && Array.isArray(args.questions)) {
3515
+ return t("tool.ask", { header: args.questions.map((q) => q && q.header).filter(Boolean).join(" · ") });
3516
+ }
3517
+ // The engine's own title when a tool wrote one, like Bash(npm test). Its
3518
+ // default is the bare name followed by a JSON preview, which says less than
3519
+ // the readable name does and is cut off mid-string.
3520
+ if (fallback && fallback !== name && !fallback.startsWith(name + "(")) return fallback;
3521
+ return friendlyToolName(name);
3522
+ }
3523
+
3524
+ /** Parameters as a table: text keeps its line breaks, short lists become chips. */
3525
+ function paramTable(params) {
3526
+ const table = el("div", "kv");
3527
+ for (const [key, value] of Object.entries(params)) {
3528
+ const cell = el("div", "kv-v");
3529
+ if (value === null || value === undefined || value === "") {
3530
+ cell.textContent = "—";
3531
+ cell.classList.add("empty");
3532
+ } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
3533
+ cell.textContent = String(value);
3534
+ } else if (Array.isArray(value) && value.length && value.every((item) => typeof item === "string" || typeof item === "number")) {
3535
+ for (const item of value) cell.appendChild(el("span", "kv-chip", String(item)));
3536
+ } else {
3537
+ const pre = el("pre", "mono");
3538
+ pre.textContent = JSON.stringify(value, null, 2);
3539
+ cell.appendChild(pre);
3540
+ }
3541
+ table.append(el("div", "kv-k", key), cell);
3542
+ }
3543
+ return table;
3544
+ }
3545
+
3546
+ /**
3547
+ * One line for a catalog answer, which is otherwise a single line of JSON.
3548
+ * Written against the fields rather than a schema: a shape it does not
3549
+ * recognise falls back to showing the raw answer, never to a wrong sentence.
3550
+ */
3551
+ function catalogSummary(text) {
3552
+ let data;
3553
+ try { data = JSON.parse(text); } catch { return ""; }
3554
+ if (!data || typeof data !== "object") return "";
3555
+ if (typeof data.app === "string") learnCloudApp(data.app, data.label, data.logo);
3556
+ if (Array.isArray(data.apps)) {
3557
+ const names = data.apps.map((app) => {
3558
+ if (typeof app === "string") return cloudAppName(app);
3559
+ if (!app || typeof app !== "object") return "";
3560
+ const slug = app.slug || app.app || app.id;
3561
+ learnCloudApp(slug, app.label || app.name, app.logo);
3562
+ return app.label || app.name || (slug ? cloudAppName(slug) : "");
3563
+ }).filter(Boolean);
3564
+ return names.length ? t("tool.catalogApps", { n: names.length, names: names.join(", ") }) : "";
3565
+ }
3566
+ if (Array.isArray(data.tools) && typeof data.app === "string") {
3567
+ return t("tool.catalogTools", { app: cloudAppName(data.app), n: data.tools.length });
3568
+ }
3569
+ return "";
3570
+ }
3571
+
2805
3572
  function toolCard(id, title, name, input) {
2806
3573
  const card = el("details", "tool");
2807
3574
  const summary = el("summary");
2808
3575
  summary.appendChild(el("span", "dot"));
2809
- summary.appendChild(el("span", "name", title || name));
3576
+ summary.appendChild(el("span", "name", toolTitle(name, input, title)));
2810
3577
  summary.appendChild(el("span", "ms", ""));
2811
3578
  card.appendChild(summary);
2812
3579
 
2813
3580
  const body = el("div", "tool-body");
2814
- body.appendChild(el("div", "label", "input"));
2815
- const pre = el("pre", "mono");
2816
- pre.textContent = JSON.stringify(input, null, 2);
2817
- body.appendChild(pre);
3581
+ // An app call is its arguments; the app, tool and version are already the
3582
+ // title or plumbing. A call with nothing to show gets no "input" label over
3583
+ // an empty {} — which was most of what a catalog lookup used to draw. A
3584
+ // question has its own card.
3585
+ const shown = name === "cloud_app_execute" && input && typeof input === "object" ? input.arguments : input;
3586
+ if (name !== "ask_user" && !isEmptyInput(shown)) {
3587
+ body.appendChild(el("div", "label", "input"));
3588
+ const tabular = shown && typeof shown === "object" && !Array.isArray(shown) &&
3589
+ (name.startsWith("cloud_app_") || name.startsWith("mcp__"));
3590
+ if (tabular) body.appendChild(paramTable(shown));
3591
+ else {
3592
+ const pre = el("pre", "mono");
3593
+ pre.textContent = JSON.stringify(shown, null, 2);
3594
+ body.appendChild(pre);
3595
+ }
3596
+ }
2818
3597
  card.appendChild(body);
2819
3598
 
2820
3599
  const group = workGroup();
@@ -2844,7 +3623,18 @@ function finishTool(id, result, ms, isError) {
2844
3623
  } else {
2845
3624
  const pre = el("pre", "mono");
2846
3625
  pre.textContent = text.length > 4000 ? text.slice(0, 4000) + "\n… truncated" : text;
2847
- entry.body.appendChild(pre);
3626
+ const summary = entry.name === "cloud_app_tools" && !isError ? catalogSummary(text) : "";
3627
+ if (summary) {
3628
+ entry.body.appendChild(el("div", "tool-summary", summary));
3629
+ const raw = el("details", "tool-raw");
3630
+ raw.appendChild(el("summary", "", t("tool.rawResult")));
3631
+ raw.appendChild(pre);
3632
+ entry.body.appendChild(raw);
3633
+ // The answer may have named the app better than its slug did.
3634
+ entry.card.querySelector(".name").textContent = toolTitle(entry.name, entry.input);
3635
+ } else {
3636
+ entry.body.appendChild(pre);
3637
+ }
2848
3638
  }
2849
3639
  if (isError) {
2850
3640
  // A failed step is the one thing worth surfacing without a click.
@@ -2997,6 +3787,7 @@ function applyState(s) {
2997
3787
  if (!s) return;
2998
3788
  if (s.model) modelPicker.set(modelPicker.options || [], s.model);
2999
3789
  if (s.mode) modePicker.set(MODES, s.mode);
3790
+ effortPicker.set(s);
3000
3791
  if (s.cwd) {
3001
3792
  // Kept so the approval dialog can show a path the way the user thinks of
3002
3793
  // it — src/web/page.ts, not the whole absolute path they already know.
@@ -3069,7 +3860,9 @@ function stopWorking() {
3069
3860
  function setBusy(busy) {
3070
3861
  if (P) P.busy = busy;
3071
3862
  paintTabs();
3863
+ // While a turn runs, the send arrow becomes a stop square in the same spot.
3072
3864
  sendBtn.disabled = busy;
3865
+ sendBtn.hidden = busy;
3073
3866
  stopBtn.hidden = !busy;
3074
3867
  if (!busy) stopWorking();
3075
3868
  }
@@ -3124,9 +3917,21 @@ function projectHead(cwd, name, count, open) {
3124
3917
  if ((projects.find((project) => project.path === cwd) || {}).pinned) {
3125
3918
  head.appendChild(svgIcon("pin", "pin-mark"));
3126
3919
  }
3127
- if (count) head.appendChild(el("span", "fold-count", String(count)));
3128
3920
  head.addEventListener("click", () => toggleProject(cwd, open));
3129
3921
 
3922
+ // A fresh session in this project without switching to it first. Where the
3923
+ // count used to sit: the count said how much was there, and this is the one
3924
+ // thing people came to the header to do about it.
3925
+ const add = el("button", "more fold-new");
3926
+ add.type = "button";
3927
+ add.title = t("nav.new");
3928
+ add.setAttribute("aria-label", t("nav.new"));
3929
+ add.appendChild(navIcon("new"));
3930
+ add.addEventListener("click", (e) => {
3931
+ e.stopPropagation();
3932
+ void switchTo({ action: "new", cwd });
3933
+ });
3934
+
3130
3935
  /*
3131
3936
  * Deleting a project is deleting its transcripts, and it lives here rather
3132
3937
  * than beside "remove from list" in the picker because this header is the
@@ -3193,6 +3998,7 @@ function projectHead(cwd, name, count, open) {
3193
3998
  // The header is a button, so the menu control cannot live inside it.
3194
3999
  const wrap = el("div", "fold-row");
3195
4000
  wrap.appendChild(head);
4001
+ wrap.appendChild(add);
3196
4002
  wrap.appendChild(more);
3197
4003
 
3198
4004
  draggable(wrap, {
@@ -3493,6 +4299,8 @@ function closeRowMenu() {
3493
4299
  menuRow = null;
3494
4300
  rowMenu.hidden = true;
3495
4301
  rowMenu.replaceChildren();
4302
+ rowMenu.classList.remove("fit-anchor", "account-menu");
4303
+ rowMenu.style.width = "";
3496
4304
  }
3497
4305
 
3498
4306
  /**
@@ -3505,8 +4313,9 @@ function placeRowMenu(button) {
3505
4313
  const anchor = button.getBoundingClientRect();
3506
4314
  const box = rowMenu.getBoundingClientRect();
3507
4315
  const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
3508
- const below = anchor.bottom + 4;
3509
- const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - 4 : below;
4316
+ const gap = rowMenu.classList.contains("account-menu") ? 8 : 4;
4317
+ const below = anchor.bottom + gap;
4318
+ const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - gap : below;
3510
4319
  rowMenu.style.left = Math.max(8, left) + "px";
3511
4320
  rowMenu.style.top = Math.max(8, top) + "px";
3512
4321
  }
@@ -3884,6 +4693,7 @@ const connectionCards = new Map();
3884
4693
 
3885
4694
  function connectionCard(card) {
3886
4695
  connectionCards.set(card.id, card);
4696
+ learnCloudApp(card.app, card.label, card.logo);
3887
4697
  const target = pane(card.session);
3888
4698
  let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
3889
4699
  if (!box) {
@@ -4015,6 +4825,11 @@ function renderHistory(entries, into) {
4015
4825
  for (const card of connectionCards.values()) {
4016
4826
  if (pane(card.session) === P) connectionCard(card);
4017
4827
  }
4828
+ // A question still waiting, drawn again under the history that just
4829
+ // replaced the pane. Settled ones are already in it, as their tool card.
4830
+ for (const entry of questionCards.values()) {
4831
+ if (entry.status === "open" && pane(entry.session) === P) paintQuestion(entry);
4832
+ }
4018
4833
  atBottom = true;
4019
4834
  log.scrollTop = log.scrollHeight;
4020
4835
  }
@@ -4176,11 +4991,27 @@ events.onmessage = (message) => {
4176
4991
  if (wasSelected) void showArtifact(event.artifact.id);
4177
4992
  // First artifact of the session opens the drawer, the way the first file
4178
4993
  // change makes the review button appear. Later ones do not steal focus.
4179
- if (artifacts.length === 1) shelf.classList.add("open");
4994
+ // Not on a phone, where the drawer is the whole screen: it would cover
4995
+ // the reply that produced the artifact.
4996
+ if (artifacts.length === 1 && !isPhone()) shelf.classList.add("open");
4180
4997
  break;
4181
4998
  }
4182
4999
 
4183
5000
  case "permission_request": showAsk(event); break;
5001
+ case "question_request": questionCard(event); break;
5002
+ case "question_result": settleQuestion(event); break;
5003
+ // Sent once per (re)connect, after the replay: a card this page still
5004
+ // shows as open but the server no longer holds closed while we were away.
5005
+ case "pending_interactions":
5006
+ for (const entry of questionCards.values()) {
5007
+ if ((entry.status === "open" || entry.status === "sending") && !event.ids.includes(entry.id)) closeQuestion(entry.id);
5008
+ }
5009
+ break;
5010
+ // Answered in another window, or the turn waiting on it stopped.
5011
+ case "interaction_closed":
5012
+ withdrawAsk(event.id);
5013
+ closeQuestion(event.id);
5014
+ break;
4184
5015
  case "permission_result":
4185
5016
  if (!event.allowed) addNotice("denied: " + (event.reason || ""), "err");
4186
5017
  else if (event.remembered) addNotice("remembered: " + event.remembered);
@@ -4192,6 +5023,12 @@ events.onmessage = (message) => {
4192
5023
  break;
4193
5024
 
4194
5025
  case "model_changed": addNotice(event.from + " → " + event.to); break;
5026
+ case "memory_recall":
5027
+ addNotice(t("memory.recalled", { count: event.notes.length, list: event.notes.map((note) => note.summary).join("; ") }));
5028
+ break;
5029
+ case "memory_extracted":
5030
+ addNotice(t("memory.extracted", { count: event.notes.length, list: event.notes.map((note) => note.summary).join("; ") }));
5031
+ break;
4195
5032
  case "notice": addNotice(event.message); break;
4196
5033
  case "error":
4197
5034
  P.streaming = null;
@@ -4288,6 +5125,12 @@ const askExpand = document.getElementById("ask-expand");
4288
5125
  const askRaw = document.getElementById("ask-raw");
4289
5126
  const askScope = document.getElementById("ask-scope");
4290
5127
  let askState = null;
5128
+ /**
5129
+ * Requests that arrived while another was on screen, oldest first. A second
5130
+ * request used to overwrite the first one's id, which left that turn waiting
5131
+ * on an answer nobody could give any more.
5132
+ */
5133
+ const askQueue = [];
4291
5134
 
4292
5135
  /**
4293
5136
  * The rule a "remember" answer would write.
@@ -4328,65 +5171,123 @@ function askSize(text) {
4328
5171
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
4329
5172
  }
4330
5173
 
4331
- const askLines = (text, kind) =>
4332
- text.split("\n").map((s) => ({ s, kind }));
5174
+ /** Lines of a payload. A file ending in a newline has no empty last line to show. */
5175
+ const askLines = (text, kind) => {
5176
+ const lines = text.split("\n");
5177
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
5178
+ return lines.map((s) => ({ s, kind }));
5179
+ };
4333
5180
 
4334
5181
  /**
4335
5182
  * The request, rearranged for a reader.
4336
5183
  *
4337
5184
  * The payload of a write is the file content, not the JSON object carrying it;
4338
5185
  * the payload of an edit is the substitution, which only means anything as a
4339
- * diff. Tools with no special handling still get their parameters, just as
4340
- * indented JSON under the toggle rather than as the headline.
5186
+ * diff. A command leads with what it is for when the model said, and shows
5187
+ * the command whole underneath. Other tools get their parameters as a table,
5188
+ * with the JSON behind the toggle — and a call with no parameters gets no body.
4341
5189
  */
4342
5190
  function askView(request) {
4343
5191
  const name = (request.toolName || "").toLowerCase();
4344
5192
  const input = request.input && typeof request.input === "object" ? request.input : {};
4345
- const view = { tag: t("ask.tagCall"), target: request.title, meta: "", lines: [] };
5193
+ const view = {
5194
+ tag: t("ask.tagCall"),
5195
+ target: toolTitle(request.toolName, input, request.title),
5196
+ path: "",
5197
+ mono: false,
5198
+ logo: undefined,
5199
+ meta: [],
5200
+ lines: [],
5201
+ code: "",
5202
+ params: null,
5203
+ };
4346
5204
 
4347
5205
  if (name === "write" && typeof input.content === "string") {
4348
- const count = input.content === "" ? 0 : input.content.split("\n").length;
4349
5206
  view.tag = t("ask.tagWrite");
4350
- view.target = askPath(input.path);
4351
- view.meta = [
4352
- count === 1 ? t("ask.oneLine") : t("ask.nLines", { n: count }),
5207
+ view.path = askPath(input.path);
5208
+ view.lines = input.content === "" ? [] : askLines(input.content, "");
5209
+ view.meta.push(
5210
+ view.lines.length === 1 ? t("ask.oneLine") : t("ask.nLines", { n: view.lines.length }),
4353
5211
  askSize(input.content),
4354
- ].join(" · ");
4355
- view.lines = askLines(input.content, "");
5212
+ );
4356
5213
  return view;
4357
5214
  }
4358
5215
 
4359
5216
  if (name === "edit" && typeof input.newString === "string") {
4360
- const removed = askLines(String(input.oldString ?? ""), "del");
4361
- const added = askLines(input.newString, "add");
5217
+ const removed = input.oldString ? askLines(String(input.oldString), "del") : [];
5218
+ const added = input.newString ? askLines(input.newString, "add") : [];
4362
5219
  view.tag = input.replaceAll ? t("ask.tagEditAll") : t("ask.tagEdit");
4363
- view.target = askPath(input.path);
4364
- view.meta = t("ask.diffStat", { added: added.length, removed: removed.length });
5220
+ view.path = askPath(input.path);
5221
+ view.meta.push(t("ask.diffStat", { added: added.length, removed: removed.length }));
4365
5222
  view.lines = removed.concat(added);
4366
5223
  return view;
4367
5224
  }
4368
5225
 
4369
5226
  if (name === "bash" && typeof input.command === "string") {
4370
5227
  view.tag = t("ask.tagBash");
4371
- // The command is the thing being judged, so it is the headline and there
4372
- // is no body to fold under it.
4373
- view.target = input.command;
4374
- view.meta = typeof input.description === "string" ? input.description : "";
5228
+ if (typeof input.description === "string" && input.description.trim()) {
5229
+ view.target = input.description.trim();
5230
+ view.code = input.command;
5231
+ } else {
5232
+ view.target = input.command;
5233
+ view.mono = true;
5234
+ }
5235
+ return view;
5236
+ }
5237
+
5238
+ if (name === "cloud_app_execute" || name === "cloud_app_tools") {
5239
+ view.tag = t("ask.tagApp");
5240
+ if (typeof input.app === "string") view.logo = cloudAppLogo(input.app);
5241
+ if (name === "cloud_app_execute" && !isEmptyInput(input.arguments)) view.params = input.arguments;
4375
5242
  return view;
4376
5243
  }
4377
5244
 
4378
5245
  if (typeof input.url === "string") {
4379
5246
  view.tag = t("ask.tagFetch");
4380
5247
  view.target = input.url;
5248
+ view.mono = true;
5249
+ const rest = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "url"));
5250
+ if (!isEmptyInput(rest)) view.params = rest;
4381
5251
  return view;
4382
5252
  }
4383
5253
 
4384
- if (typeof input.path === "string") view.target = askPath(input.path);
4385
- else if (typeof input.pattern === "string") view.target = input.pattern;
4386
- view.lines = askLines(JSON.stringify(input, null, 2), "");
5254
+ if (typeof input.path === "string") view.meta.push(askPath(input.path));
5255
+ else if (typeof input.pattern === "string") view.meta.push(input.pattern);
5256
+ if (!isEmptyInput(input)) view.params = input;
4387
5257
  return view;
4388
5258
  }
4389
5259
 
5260
+ function paintAskHead(view, request) {
5261
+ document.getElementById("ask-tag").textContent = view.tag;
5262
+ const target = document.getElementById("ask-target");
5263
+ target.replaceChildren();
5264
+ target.classList.toggle("mono", view.mono || Boolean(view.path));
5265
+ if (view.path) {
5266
+ // The directory is context; the file name is the thing being approved.
5267
+ const cut = view.path.lastIndexOf("/");
5268
+ if (cut >= 0) target.appendChild(el("span", "dir", view.path.slice(0, cut + 1)));
5269
+ target.appendChild(el("span", "base", view.path.slice(cut + 1)));
5270
+ } else {
5271
+ target.textContent = view.target || "";
5272
+ }
5273
+
5274
+ const logo = document.getElementById("ask-logo");
5275
+ logo.hidden = !view.logo;
5276
+ if (view.logo) {
5277
+ logo.onerror = () => { logo.hidden = true; };
5278
+ logo.src = view.logo;
5279
+ } else {
5280
+ logo.removeAttribute("src");
5281
+ }
5282
+
5283
+ const meta = document.getElementById("ask-meta");
5284
+ // Which rule made the harness ask belongs with the other metadata, and in
5285
+ // the page's own language rather than the engine's English sentence.
5286
+ const rule = request.rule ? t("ask.rule", { rule: request.rule }) : request.reason;
5287
+ meta.textContent = view.meta.concat(rule ? [rule] : []).filter(Boolean).join(" · ");
5288
+ meta.hidden = meta.textContent === "";
5289
+ }
5290
+
4390
5291
  /** A path shown relative to the working directory when it sits inside it. */
4391
5292
  function askPath(value) {
4392
5293
  if (typeof value !== "string") return "";
@@ -4396,52 +5297,81 @@ function askPath(value) {
4396
5297
 
4397
5298
  function renderAskBody() {
4398
5299
  if (!askState) return;
5300
+ const { view, request } = askState;
4399
5301
  askBody.replaceChildren();
4400
- const all = askState.view.lines;
4401
- const shown = askState.raw
4402
- ? askLines(JSON.stringify(askState.request.input, null, 2), "")
4403
- : askState.expanded ? all : all.slice(0, ASK_FOLD);
4404
- for (const [index, line] of shown.entries()) {
4405
- const row = el("div", "ask-line" + (line.kind ? " " + line.kind : ""));
4406
- // A diff has no line numbers to give: the replacement has not happened, so
4407
- // counting the rows would name positions that do not exist in the file.
4408
- const gutter = line.kind === "del" ? "−" : line.kind === "add" ? "+" : String(index + 1);
4409
- row.append(el("span", "n", gutter), el("span", "s", line.s || " "));
4410
- askBody.append(row);
4411
- }
4412
- askBody.classList.toggle("clamped", !askState.expanded && !askState.raw);
4413
- askBody.hidden = shown.length === 0;
4414
-
4415
- const foldable = !askState.raw && all.length > ASK_FOLD;
4416
- askExpand.hidden = !foldable;
4417
- askExpand.textContent = askState.expanded ? t("ask.collapse") : t("ask.expand", { n: all.length });
5302
+ askBody.classList.remove("diff", "clamped");
5303
+ let foldable = false;
5304
+
5305
+ if (askState.raw) {
5306
+ const pre = el("pre", "ask-code");
5307
+ pre.textContent = JSON.stringify(request.input, null, 2);
5308
+ askBody.appendChild(pre);
5309
+ } else if (view.lines.length) {
5310
+ const shown = askState.expanded ? view.lines : view.lines.slice(0, ASK_FOLD);
5311
+ foldable = view.lines.length > ASK_FOLD;
5312
+ askBody.classList.toggle("diff", view.lines.some((line) => line.kind));
5313
+ for (const [index, line] of shown.entries()) {
5314
+ const row = el("div", "ask-line" + (line.kind ? " " + line.kind : ""));
5315
+ // A diff has no line numbers to give: the replacement has not happened, so
5316
+ // counting the rows would name positions that do not exist in the file.
5317
+ const gutter = line.kind === "del" ? "−" : line.kind === "add" ? "+" : String(index + 1);
5318
+ row.append(el("span", "n", gutter), el("span", "s", line.s || " "));
5319
+ askBody.appendChild(row);
5320
+ }
5321
+ askBody.classList.toggle("clamped", !askState.expanded);
5322
+ } else if (view.code) {
5323
+ const pre = el("pre", "ask-code");
5324
+ pre.textContent = view.code;
5325
+ askBody.appendChild(pre);
5326
+ } else if (view.params) {
5327
+ askBody.appendChild(paramTable(view.params));
5328
+ }
5329
+ askBody.hidden = askBody.childElementCount === 0;
5330
+
5331
+ askExpand.hidden = askState.raw || !foldable;
5332
+ askExpand.textContent = askState.expanded ? t("ask.collapse") : t("ask.expand", { n: view.lines.length });
5333
+ // Raw parameters only where the body is a rearrangement of them. A file or
5334
+ // a command is already shown whole, and an empty call has nothing to reveal.
5335
+ // This button used to be shown always, which on a command left one orphaned
5336
+ // row between two rules.
5337
+ askRaw.hidden = !view.params;
4418
5338
  askRaw.textContent = askState.raw ? t("ask.rawHide") : t("ask.raw");
4419
5339
  askMore.hidden = askExpand.hidden && askRaw.hidden;
4420
5340
  }
4421
5341
 
4422
5342
  function showAsk(event) {
5343
+ if (pendingId === event.id || askQueue.some((queued) => queued.id === event.id)) return;
5344
+ if (pendingId) return void askQueue.push(event);
4423
5345
  pendingId = event.id;
4424
5346
  const request = event.request;
4425
5347
  const view = askView(request);
4426
5348
  askState = { request, view, expanded: false, raw: false };
4427
5349
 
4428
- document.getElementById("ask-tag").textContent = view.tag;
4429
- document.getElementById("ask-tool").textContent = request.toolName || "";
4430
- document.getElementById("ask-target").textContent = view.target || "";
4431
- const meta = document.getElementById("ask-meta");
4432
- // The reason explains which rule made the harness ask; it belongs with the
4433
- // other metadata, not above the thing being approved.
4434
- meta.textContent = [view.meta, request.reason].filter(Boolean).join(" · ");
4435
- meta.hidden = meta.textContent === "";
5350
+ paintAskHead(view, request);
4436
5351
  askScope.textContent = "";
5352
+ askScope.hidden = true;
4437
5353
  renderAskBody();
4438
5354
  // The palette is a convenience; this is a turn waiting on an answer. Two
4439
5355
  // stacked modals would leave the deeper one unreachable behind the newer.
4440
5356
  if (paletteDialog.open) paletteDialog.close();
4441
- ask.showModal();
5357
+ if (!ask.open) ask.showModal();
4442
5358
  ask.querySelector('[data-answer="once"]').focus();
4443
5359
  }
4444
5360
 
5361
+ function nextAsk() {
5362
+ const next = askQueue.shift();
5363
+ if (next) showAsk(next);
5364
+ }
5365
+
5366
+ /** Taken down without an answer: another window answered it, or its turn stopped. */
5367
+ function withdrawAsk(id) {
5368
+ const queued = askQueue.findIndex((entry) => entry.id === id);
5369
+ if (queued >= 0) askQueue.splice(queued, 1);
5370
+ if (pendingId !== id) return;
5371
+ pendingId = null;
5372
+ ask.close();
5373
+ }
5374
+
4445
5375
  askExpand.addEventListener("click", () => {
4446
5376
  askState.expanded = !askState.expanded;
4447
5377
  renderAskBody();
@@ -4456,6 +5386,9 @@ ask.addEventListener("close", () => {
4456
5386
  // Escape, the backdrop, anything other than a button: a dismissal is a
4457
5387
  // refusal. Approval has to be an explicit act.
4458
5388
  if (pendingId) answer(false, null);
5389
+ askState = null;
5390
+ // After the close has finished: showModal on a dialog still closing throws.
5391
+ setTimeout(nextAsk, 0);
4459
5392
  });
4460
5393
 
4461
5394
  // Enter approves once, which is the answer the dialog is focused on anyway.
@@ -4470,15 +5403,8 @@ ask.addEventListener("keydown", (event) => {
4470
5403
  for (const button of ask.querySelectorAll("[data-answer]")) {
4471
5404
  button.addEventListener("click", () => {
4472
5405
  const choice = button.dataset.answer;
4473
- const id = pendingId;
4474
- pendingId = null;
5406
+ answer(choice !== "no", choice === "session" || choice === "project" ? choice : null);
4475
5407
  ask.close();
4476
- if (!id) return;
4477
- void api("/permission", {
4478
- id,
4479
- allow: choice !== "no",
4480
- remember: choice === "session" || choice === "project" ? choice : undefined,
4481
- });
4482
5408
  });
4483
5409
 
4484
5410
  // What a scope actually costs, spelled out before it is chosen rather than
@@ -4490,11 +5416,16 @@ for (const button of ask.querySelectorAll("[data-answer]")) {
4490
5416
  askScope.textContent = t(scope === "session" ? "ask.scopeSession" : "ask.scopeProject", {
4491
5417
  key: askRuleKey(askState.request),
4492
5418
  });
5419
+ askScope.hidden = false;
5420
+ };
5421
+ const hide = () => {
5422
+ askScope.textContent = "";
5423
+ askScope.hidden = true;
4493
5424
  };
4494
5425
  button.addEventListener("mouseenter", describe);
4495
5426
  button.addEventListener("focus", describe);
4496
- button.addEventListener("mouseleave", () => { askScope.textContent = ""; });
4497
- button.addEventListener("blur", () => { askScope.textContent = ""; });
5427
+ button.addEventListener("mouseleave", hide);
5428
+ button.addEventListener("blur", hide);
4498
5429
  }
4499
5430
 
4500
5431
  function answer(allow, remember) {
@@ -4504,7 +5435,390 @@ function answer(allow, remember) {
4504
5435
  // Which way the answer went and how far it was remembered — never what was
4505
5436
  // being asked about, which is a command line or a path.
4506
5437
  track("permission_answered", { allow: allow, remember: remember || "once" });
4507
- void api("/permission", { id, allow, remember });
5438
+ void api("/permission", { id, allow, remember: remember || undefined });
5439
+ }
5440
+
5441
+
5442
+ // --- questions -------------------------------------------------------------
5443
+ // The ask_user tool. The card lives in the transcript of its own session, is
5444
+ // drawn again after a reload (the server replays what is still waiting), and
5445
+ // ends as one line saying how it was answered.
5446
+
5447
+ /** Every question this page has seen, by the server's interaction id. */
5448
+ const questionCards = new Map();
5449
+
5450
+ function questionCard(event) {
5451
+ // Only a server interaction can be answered; anything without its id is not one.
5452
+ if (typeof event.id !== "string") return;
5453
+ let entry = questionCards.get(event.id);
5454
+ if (!entry) {
5455
+ const questions = (event.request && event.request.questions) || [];
5456
+ entry = {
5457
+ id: event.id,
5458
+ session: event.session,
5459
+ request: event.request,
5460
+ step: 0,
5461
+ preview: 0,
5462
+ status: "open",
5463
+ error: "",
5464
+ result: null,
5465
+ picks: Object.fromEntries(questions.map((q) => [q.id, { selected: [], other: "" }])),
5466
+ };
5467
+ questionCards.set(event.id, entry);
5468
+ }
5469
+ paintQuestion(entry);
5470
+ const box = questionBox(entry);
5471
+ if (pane(entry.session) === panes.get(activeId)) {
5472
+ follow();
5473
+ // Focus moves to the card only when nothing is being typed: a question
5474
+ // arriving mid-sentence must not swallow the next keystrokes.
5475
+ if (document.activeElement !== input || !input.value) {
5476
+ const first = box.querySelector(".q-opt");
5477
+ if (first) first.focus({ preventScroll: true });
5478
+ }
5479
+ }
5480
+ }
5481
+
5482
+ function questionBox(entry) {
5483
+ const target = pane(entry.session);
5484
+ let box = target.node.querySelector('[data-question-id="' + entry.id + '"]');
5485
+ if (!box) {
5486
+ box = el("section", "question-card column");
5487
+ box.dataset.questionId = entry.id;
5488
+ box.tabIndex = -1;
5489
+ box.setAttribute("aria-live", "polite");
5490
+ box.addEventListener("keydown", (e) => questionKey(entry, e));
5491
+ target.node.appendChild(box);
5492
+ }
5493
+ return box;
5494
+ }
5495
+
5496
+ function openQuestion(session) {
5497
+ for (const entry of questionCards.values()) {
5498
+ if (entry.session === session && entry.status === "open") return entry;
5499
+ }
5500
+ return undefined;
5501
+ }
5502
+
5503
+ const questionAnswered = (entry, q) => {
5504
+ const pick = entry.picks[q.id];
5505
+ return Boolean(pick && (pick.selected.length || pick.other.trim()));
5506
+ };
5507
+
5508
+ function paintQuestion(entry) {
5509
+ const box = questionBox(entry);
5510
+ const focused = document.activeElement && box.contains(document.activeElement)
5511
+ ? document.activeElement.dataset.index || (document.activeElement.classList.contains("q-other-input") ? "other" : "")
5512
+ : "";
5513
+ box.replaceChildren();
5514
+ const questions = entry.request.questions || [];
5515
+ const live = entry.status === "open" || entry.status === "sending";
5516
+ box.classList.toggle("settled", !live);
5517
+ if (!live) {
5518
+ for (const node of questionSettled(entry)) box.appendChild(node);
5519
+ return;
5520
+ }
5521
+
5522
+ const q = questions[entry.step] || questions[0];
5523
+ const pick = entry.picks[q.id];
5524
+
5525
+ const head = el("div", "q-head");
5526
+ if (questions.length > 1) {
5527
+ questions.forEach((item, index) => {
5528
+ const tab = el("button", "q-tab" + (index === entry.step ? " on" : "") + (questionAnswered(entry, item) ? " done" : ""), item.header);
5529
+ tab.type = "button";
5530
+ tab.onclick = () => { entry.step = index; entry.preview = 0; paintQuestion(entry); };
5531
+ head.appendChild(tab);
5532
+ });
5533
+ } else {
5534
+ head.appendChild(el("span", "q-tag", q.header));
5535
+ }
5536
+ if (q.multiSelect) head.appendChild(el("span", "q-hint", t("question.multi")));
5537
+ box.appendChild(head);
5538
+ box.appendChild(el("div", "q-text", q.question));
5539
+
5540
+ const layout = el("div", "q-layout");
5541
+ const list = el("div", "q-options");
5542
+ list.setAttribute("role", "group");
5543
+ q.options.forEach((option, index) => {
5544
+ const on = pick.selected.includes(option.label);
5545
+ const row = el("button", "q-opt" + (on ? " on" : ""));
5546
+ row.type = "button";
5547
+ row.dataset.index = String(index);
5548
+ row.setAttribute("aria-pressed", String(on));
5549
+ row.appendChild(el("span", "q-key", String(index + 1)));
5550
+ const words = el("span", "q-words");
5551
+ words.appendChild(el("span", "q-label", option.label));
5552
+ if (option.description) words.appendChild(el("span", "q-desc", option.description));
5553
+ row.appendChild(words);
5554
+ row.appendChild(el("span", "q-check", on ? "✓" : ""));
5555
+ row.onclick = () => pickOption(entry, q, index);
5556
+ const show = () => {
5557
+ if (entry.preview === index) return;
5558
+ entry.preview = index;
5559
+ const panel = box.querySelector(".q-preview");
5560
+ if (!panel) return;
5561
+ panel.textContent = option.preview || "";
5562
+ panel.hidden = !option.preview;
5563
+ };
5564
+ row.addEventListener("mouseenter", show);
5565
+ row.addEventListener("focus", show);
5566
+ list.appendChild(row);
5567
+ });
5568
+
5569
+ const other = el("label", "q-opt q-other" + (pick.other.trim() ? " on" : ""));
5570
+ other.appendChild(el("span", "q-key", String(q.options.length + 1)));
5571
+ const field = el("input", "q-other-input");
5572
+ field.type = "text";
5573
+ field.maxLength = 2000;
5574
+ field.placeholder = t("question.other");
5575
+ field.value = pick.other;
5576
+ field.addEventListener("input", () => {
5577
+ pick.other = field.value;
5578
+ // One answer to a single-choice question: typing replaces the pick.
5579
+ if (!q.multiSelect && field.value.trim() && pick.selected.length) {
5580
+ pick.selected = [];
5581
+ for (const row of list.querySelectorAll(".q-opt[data-index]")) {
5582
+ row.classList.remove("on");
5583
+ row.setAttribute("aria-pressed", "false");
5584
+ row.querySelector(".q-check").textContent = "";
5585
+ }
5586
+ }
5587
+ other.classList.toggle("on", Boolean(field.value.trim()));
5588
+ paintQuestionActions(entry, box);
5589
+ });
5590
+ field.addEventListener("keydown", (e) => {
5591
+ if (e.key === "Enter" && !e.isComposing) {
5592
+ e.preventDefault();
5593
+ advanceQuestion(entry);
5594
+ }
5595
+ });
5596
+ other.appendChild(field);
5597
+ list.appendChild(other);
5598
+ layout.appendChild(list);
5599
+
5600
+ // Under the options, not beside them: side by side halved both inside a chat
5601
+ // column, and most options carry no preview at all. It follows the row under
5602
+ // the pointer or the keyboard, and folds away for a row that has none.
5603
+ if (q.options.some((option) => option.preview)) {
5604
+ const preview = el("pre", "q-preview mono");
5605
+ const shown = (q.options[entry.preview] || q.options[0]).preview || "";
5606
+ preview.textContent = shown;
5607
+ preview.hidden = !shown;
5608
+ layout.appendChild(preview);
5609
+ }
5610
+ box.appendChild(layout);
5611
+
5612
+ const actions = el("div", "q-actions");
5613
+ box.appendChild(actions);
5614
+ paintQuestionActions(entry, box);
5615
+ if (entry.error) box.appendChild(el("div", "q-error", entry.error));
5616
+
5617
+ // Keep the keyboard where it was across the redraw a pick causes.
5618
+ if (focused === "other") field.focus();
5619
+ else if (focused) {
5620
+ const again = list.querySelector('.q-opt[data-index="' + focused + '"]');
5621
+ if (again) again.focus();
5622
+ }
5623
+ }
5624
+
5625
+ function paintQuestionActions(entry, box) {
5626
+ const actions = box.querySelector(".q-actions");
5627
+ if (!actions) return;
5628
+ actions.replaceChildren();
5629
+ const questions = entry.request.questions || [];
5630
+ const last = entry.step >= questions.length - 1;
5631
+ const sending = entry.status === "sending";
5632
+
5633
+ // The hint holds the left end; every button sits together at the right,
5634
+ // Skip nearest the reader's way in and Submit last.
5635
+ actions.appendChild(el("span", "q-keys", t("question.keys")));
5636
+ const skip = el("button", "quiet", t("question.skip"));
5637
+ skip.type = "button";
5638
+ skip.disabled = sending;
5639
+ skip.onclick = () => void sendQuestion(entry, { outcome: "dismissed" });
5640
+ actions.appendChild(skip);
5641
+
5642
+ if (entry.step > 0) {
5643
+ const back = el("button", "", t("question.back"));
5644
+ back.type = "button";
5645
+ back.disabled = sending;
5646
+ back.onclick = () => { entry.step--; entry.preview = 0; paintQuestion(entry); };
5647
+ actions.appendChild(back);
5648
+ }
5649
+ const primary = el("button", "approve", last ? t("question.submit") : t("question.next"));
5650
+ primary.type = "button";
5651
+ primary.disabled = sending || (last
5652
+ ? !questions.every((q) => questionAnswered(entry, q))
5653
+ : !questionAnswered(entry, questions[entry.step]));
5654
+ primary.onclick = () => advanceQuestion(entry);
5655
+ actions.appendChild(primary);
5656
+ }
5657
+
5658
+ function pickOption(entry, q, index) {
5659
+ if (entry.status !== "open") return;
5660
+ const pick = entry.picks[q.id];
5661
+ const label = q.options[index].label;
5662
+ if (q.multiSelect) {
5663
+ pick.selected = pick.selected.includes(label)
5664
+ ? pick.selected.filter((chosen) => chosen !== label)
5665
+ : pick.selected.concat(label);
5666
+ } else {
5667
+ pick.selected = pick.selected[0] === label ? [] : [label];
5668
+ if (pick.selected.length) pick.other = "";
5669
+ }
5670
+ entry.preview = index;
5671
+ paintQuestion(entry);
5672
+ // A single choice with more questions after it moves on by itself; the last
5673
+ // one still waits for Submit, so a slip of the finger is not sent.
5674
+ const questions = entry.request.questions;
5675
+ if (!q.multiSelect && pick.selected.length && entry.step < questions.length - 1) {
5676
+ entry.step++;
5677
+ entry.preview = 0;
5678
+ paintQuestion(entry);
5679
+ const first = questionBox(entry).querySelector(".q-opt");
5680
+ if (first) first.focus();
5681
+ }
5682
+ }
5683
+
5684
+ function advanceQuestion(entry) {
5685
+ if (entry.status !== "open") return;
5686
+ const questions = entry.request.questions;
5687
+ if (!questionAnswered(entry, questions[entry.step])) return;
5688
+ if (entry.step < questions.length - 1) {
5689
+ entry.step++;
5690
+ entry.preview = 0;
5691
+ paintQuestion(entry);
5692
+ const first = questionBox(entry).querySelector(".q-opt");
5693
+ if (first) first.focus();
5694
+ return;
5695
+ }
5696
+ const unanswered = questions.findIndex((q) => !questionAnswered(entry, q));
5697
+ if (unanswered >= 0) {
5698
+ entry.step = unanswered;
5699
+ return void paintQuestion(entry);
5700
+ }
5701
+ const answers = {};
5702
+ for (const q of questions) {
5703
+ const pick = entry.picks[q.id];
5704
+ const other = pick.other.trim();
5705
+ answers[q.id] = other ? { selected: pick.selected, other } : { selected: pick.selected };
5706
+ }
5707
+ void sendQuestion(entry, { outcome: "answered", answers });
5708
+ }
5709
+
5710
+ function questionKey(entry, e) {
5711
+ if (entry.status !== "open" || e.metaKey || e.ctrlKey || e.altKey) return;
5712
+ if (e.target && e.target.tagName === "INPUT") return;
5713
+ const questions = entry.request.questions;
5714
+ const q = questions[entry.step];
5715
+ if (!q) return;
5716
+ if (/^[1-9]$/.test(e.key)) {
5717
+ const n = Number(e.key);
5718
+ if (n <= q.options.length) {
5719
+ e.preventDefault();
5720
+ return void pickOption(entry, q, n - 1);
5721
+ }
5722
+ if (n === q.options.length + 1) {
5723
+ e.preventDefault();
5724
+ const field = questionBox(entry).querySelector(".q-other-input");
5725
+ if (field) field.focus();
5726
+ return;
5727
+ }
5728
+ }
5729
+ // Enter moves on rather than toggling the focused row; Space still toggles.
5730
+ if (e.key === "Enter") {
5731
+ e.preventDefault();
5732
+ return void advanceQuestion(entry);
5733
+ }
5734
+ if ((e.key === "ArrowRight" && entry.step < questions.length - 1) || (e.key === "ArrowLeft" && entry.step > 0)) {
5735
+ e.preventDefault();
5736
+ entry.step += e.key === "ArrowRight" ? 1 : -1;
5737
+ entry.preview = 0;
5738
+ paintQuestion(entry);
5739
+ const first = questionBox(entry).querySelector(".q-opt");
5740
+ if (first) first.focus();
5741
+ }
5742
+ if (e.key === "Escape") {
5743
+ e.preventDefault();
5744
+ void sendQuestion(entry, { outcome: "dismissed" });
5745
+ }
5746
+ }
5747
+
5748
+ async function sendQuestion(entry, answer) {
5749
+ if (entry.status !== "open") return;
5750
+ entry.status = "sending";
5751
+ entry.error = "";
5752
+ paintQuestion(entry);
5753
+ const reply = await api("/question", { id: entry.id, answer });
5754
+ if (reply && reply.error) {
5755
+ // Gone already means it was settled elsewhere; the result event says how.
5756
+ // A 404 is that case with the event lost on the way (a stream that
5757
+ // reconnected): the question is over, so the card says so instead of
5758
+ // offering an answer nobody is waiting for.
5759
+ if (entry.status === "sending" && reply.error === "no such pending question") {
5760
+ entry.status = "closed";
5761
+ } else if (entry.status === "sending") {
5762
+ entry.status = "open";
5763
+ entry.error = reply.error;
5764
+ }
5765
+ return void paintQuestion(entry);
5766
+ }
5767
+ // Counts and outcome only, never the questions or what was chosen.
5768
+ track("question_answered", {
5769
+ questions: entry.request.questions.length,
5770
+ outcome: answer.outcome,
5771
+ other: Boolean(answer.answers && Object.values(answer.answers).some((pick) => pick.other)),
5772
+ });
5773
+ if (entry.status === "sending" || entry.status === "closed") {
5774
+ entry.status = answer.outcome;
5775
+ entry.result = answer;
5776
+ }
5777
+ paintQuestion(entry);
5778
+ if (pane(entry.session) === panes.get(activeId)) input.focus();
5779
+ }
5780
+
5781
+ /** The engine's own account of how the question ended — authoritative over the page's. */
5782
+ function settleQuestion(event) {
5783
+ for (const entry of questionCards.values()) {
5784
+ if (entry.session !== event.session || !entry.request || entry.request.id !== event.id) continue;
5785
+ if (!event.result) continue;
5786
+ entry.status = event.result.outcome;
5787
+ entry.result = event.result;
5788
+ paintQuestion(entry);
5789
+ }
5790
+ }
5791
+
5792
+ function closeQuestion(id) {
5793
+ const entry = questionCards.get(id);
5794
+ if (!entry || (entry.status !== "open" && entry.status !== "sending")) return;
5795
+ entry.status = "closed";
5796
+ paintQuestion(entry);
5797
+ }
5798
+
5799
+ function questionSettled(entry) {
5800
+ const result = entry.result || {};
5801
+ const titles = {
5802
+ answered: "question.answered",
5803
+ dismissed: "question.dismissed",
5804
+ aborted: "question.aborted",
5805
+ unavailable: "question.unavailable",
5806
+ };
5807
+ const nodes = [el("div", "q-settled-title", t(titles[entry.status] || "question.closed"))];
5808
+ if (entry.status === "answered" && result.answers) {
5809
+ for (const q of entry.request.questions) {
5810
+ const pick = result.answers[q.id] || {};
5811
+ const parts = (pick.selected || []).slice();
5812
+ if (pick.other) parts.push(pick.other);
5813
+ const line = el("div", "q-settled-line");
5814
+ line.append(el("span", "q-settled-head", q.header), el("span", "", parts.join(", ")));
5815
+ nodes.push(line);
5816
+ }
5817
+ }
5818
+ if (entry.status === "dismissed" && result.note) {
5819
+ nodes.push(el("div", "q-settled-line", t("question.note", { note: result.note })));
5820
+ }
5821
+ return nodes;
4508
5822
  }
4509
5823
 
4510
5824
 
@@ -4721,7 +6035,7 @@ function attachRows() {
4721
6035
  // draws; a plug in front of three names is three rows told apart by
4722
6036
  // reading.
4723
6037
  mark: kind === "connectors" ? entry.name : undefined,
4724
- label: entry.name,
6038
+ label: entry.title || entry.name,
4725
6039
  note: entry.description || "",
4726
6040
  ref: {
4727
6041
  kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
@@ -4828,13 +6142,14 @@ function attachRows() {
4828
6142
  .filter((plugin) =>
4829
6143
  !query ||
4830
6144
  plugin.name.toLowerCase().includes(query) ||
6145
+ (plugin.title || "").toLowerCase().includes(query) ||
4831
6146
  (plugin.description || "").toLowerCase().includes(query),
4832
6147
  )
4833
6148
  .slice(0, 4)
4834
6149
  .map((plugin) => ({
4835
6150
  type: "plugin",
4836
6151
  icon: "plugin",
4837
- label: plugin.name,
6152
+ label: plugin.title || plugin.name,
4838
6153
  note: plugin.description || "",
4839
6154
  ref: { kind: "plugin", value: plugin.name, label: plugin.name },
4840
6155
  }));
@@ -5140,6 +6455,7 @@ function paintMarket() {
5140
6455
  const hit = (entry) =>
5141
6456
  !query ||
5142
6457
  (entry.name || "").toLowerCase().includes(query) ||
6458
+ (entry.title || "").toLowerCase().includes(query) ||
5143
6459
  (entry.description || "").toLowerCase().includes(query) ||
5144
6460
  (entry.label || "").toLowerCase().includes(query);
5145
6461
  const installed = (
@@ -5256,7 +6572,9 @@ function installedRow(entry) {
5256
6572
  row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
5257
6573
 
5258
6574
  const naming = el("div", "mk-naming");
5259
- const heading = el("div", "mk-name", entry.name);
6575
+ // A plugin may carry a display title over its id; the id is still what a
6576
+ // reference resolves by, so it is the title that is painted and nothing else.
6577
+ const heading = el("div", "mk-name", entry.title || entry.name);
5260
6578
  /**
5261
6579
  * Where it came from, when that is not "this machine".
5262
6580
  *
@@ -5718,7 +7036,8 @@ function openCommands(command) {
5718
7036
  attachPicker.classList.add("open");
5719
7037
  const bar = attachPicker.getBoundingClientRect().top;
5720
7038
  const box = composerBox.getBoundingClientRect().top;
5721
- attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
7039
+ // On a phone the menu is positioned from the box itself by the stylesheet.
7040
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
5722
7041
  paintAttachMenu();
5723
7042
  }
5724
7043
 
@@ -5775,7 +7094,8 @@ function openMention(mention) {
5775
7094
  // the sentence it is going into.
5776
7095
  const bar = attachPicker.getBoundingClientRect().top;
5777
7096
  const box = composerBox.getBoundingClientRect().top;
5778
- attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
7097
+ // On a phone the menu is positioned from the box itself by the stylesheet.
7098
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
5779
7099
  paintAttachMenu();
5780
7100
  searchMentionFiles();
5781
7101
  }
@@ -5876,6 +7196,17 @@ document.getElementById("composer").addEventListener("submit", (e) => {
5876
7196
  closeAttachMenu();
5877
7197
  return;
5878
7198
  }
7199
+ // A question waiting in this tab takes what is typed as its answer. People
7200
+ // answer "none of these — I want..." in the composer, and a second turn could
7201
+ // not start while this one waits anyway.
7202
+ const waiting = openQuestion(activeId);
7203
+ if (waiting && text && !staged.length) {
7204
+ input.value = "";
7205
+ input.style.height = "auto";
7206
+ paintMirror();
7207
+ void sendQuestion(waiting, { outcome: "dismissed", note: text });
7208
+ return;
7209
+ }
5879
7210
  if (!text && !staged.length && !refs.length) return;
5880
7211
  const ids = staged.map((file) => file.id);
5881
7212
  const shots = staged
@@ -5924,12 +7255,54 @@ stopBtn.addEventListener("click", () => void api("/interrupt", { session: active
5924
7255
  // almost every time, and a dialog in front of every new session charges the
5925
7256
  // common case for the rare one.
5926
7257
  document.getElementById("new").addEventListener("click", () => void switchTo({ action: "new" }));
7258
+ /**
7259
+ * The sidebar as a drawer, on a phone.
7260
+ *
7261
+ * Open from the burger, closed from the scrim, the mark in its own corner,
7262
+ * Escape, or by choosing anything in it: a drawer that stayed over the
7263
+ * transcript after a session was picked hid the very thing that was picked.
7264
+ */
7265
+ const sidebar = document.querySelector("body > aside");
7266
+ function setNavOpen(open) {
7267
+ sidebar.classList.toggle("open", open);
7268
+ document.body.classList.toggle("nav-open", open);
7269
+ }
7270
+ function closeNav() {
7271
+ if (sidebar.classList.contains("open")) setNavOpen(false);
7272
+ }
5927
7273
  document.getElementById("burger").addEventListener("click", () =>
5928
- document.querySelector("aside").classList.toggle("open"));
5929
-
5930
-
5931
- // --- review pane (the Codex-shaped part) ----------------------------------
5932
- // The deliverable of a coding agent is the diff, so changed files get a pane
7274
+ setNavOpen(!sidebar.classList.contains("open")));
7275
+ document.getElementById("nav-close").addEventListener("click", () => setNavOpen(false));
7276
+ document.getElementById("scrim").addEventListener("click", () => setNavOpen(false));
7277
+ sidebar.addEventListener("click", (e) => {
7278
+ if (!isPhone() || e.target.closest(".more")) return;
7279
+ if (e.target.closest(".session, .nav-item, .project")) closeNav();
7280
+ });
7281
+ document.addEventListener("keydown", (e) => {
7282
+ if (e.key === "Escape" && sidebar.classList.contains("open")) closeNav();
7283
+ });
7284
+ document.getElementById("review-close").addEventListener("click", () =>
7285
+ review.classList.remove("open"));
7286
+
7287
+ // The keyboard takes the bottom of the screen and iOS does not shrink the
7288
+ // layout to match, so the composer would sit under it. The body follows the
7289
+ // visible viewport instead, on phones only.
7290
+ if (window.visualViewport) {
7291
+ const followKeyboard = () => {
7292
+ if (!isPhone()) {
7293
+ document.body.style.height = "";
7294
+ return;
7295
+ }
7296
+ document.body.style.height = Math.round(window.visualViewport.height) + "px";
7297
+ window.scrollTo(0, 0);
7298
+ };
7299
+ window.visualViewport.addEventListener("resize", followKeyboard);
7300
+ window.addEventListener("resize", followKeyboard);
7301
+ }
7302
+
7303
+
7304
+ // --- review pane (the Codex-shaped part) ----------------------------------
7305
+ // The deliverable of a coding agent is the diff, so changed files get a pane
5933
7306
  // of their own rather than living inside a tool card the reader has to hunt
5934
7307
  // for. Activity collapses; the change stays.
5935
7308
 
@@ -7498,6 +8871,31 @@ function discoverNow(providerId) {
7498
8871
  });
7499
8872
  }
7500
8873
 
8874
+ /** A plus in a ring: adding credits. Drawn like the sidebar's other line icons. */
8875
+ function iconTopUp() {
8876
+ const NS = "http://www.w3.org/2000/svg";
8877
+ const svg = document.createElementNS(NS, "svg");
8878
+ svg.setAttribute("class", "ico");
8879
+ svg.setAttribute("viewBox", "0 0 16 16");
8880
+ svg.setAttribute("fill", "none");
8881
+ svg.setAttribute("stroke", "currentColor");
8882
+ svg.setAttribute("stroke-width", "1.5");
8883
+ svg.setAttribute("stroke-linecap", "round");
8884
+ svg.setAttribute("stroke-linejoin", "round");
8885
+ svg.setAttribute("aria-hidden", "true");
8886
+ const ring = document.createElementNS(NS, "circle");
8887
+ ring.setAttribute("cx", "8");
8888
+ ring.setAttribute("cy", "8");
8889
+ ring.setAttribute("r", "6.67");
8890
+ svg.appendChild(ring);
8891
+ for (const d of ["M8 5.33v5.34", "M5.33 8h5.34"]) {
8892
+ const path = document.createElementNS(NS, "path");
8893
+ path.setAttribute("d", d);
8894
+ svg.appendChild(path);
8895
+ }
8896
+ return svg;
8897
+ }
8898
+
7501
8899
  /** A person, drawn to match the sidebar's other 16px line icons. */
7502
8900
  function iconPerson() {
7503
8901
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@@ -7535,6 +8933,15 @@ function iconPerson() {
7535
8933
  * and none of those can be hosted in a local window — they keep working here
7536
8934
  * precisely because this does not reimplement them.
7537
8935
  */
8936
+ /**
8937
+ * The teams the account is on, as the last account load found them.
8938
+ *
8939
+ * Kept here so the paywall can name a team by its id without a second
8940
+ * request: the id is what the billing answer carries, and the name is what
8941
+ * a person reads.
8942
+ */
8943
+ let knownTeams = [];
8944
+
7538
8945
  async function loadAccount() {
7539
8946
  const host = document.getElementById("account");
7540
8947
  if (!host) return;
@@ -7581,7 +8988,7 @@ async function loadAccount() {
7581
8988
  const name = body.name || body.email || t("account.signedIn");
7582
8989
  row.appendChild(avatarFor(name, body.avatar));
7583
8990
  row.appendChild(el("span", "who-name", name));
7584
- row.appendChild(svgIcon("caret", "acct-caret"));
8991
+ row.appendChild(svgIcon("chevronsVertical", "acct-caret"));
7585
8992
 
7586
8993
  // The balance, once it can be read. Running out mid-turn is the failure this
7587
8994
  // prevents: a number that appears only in a paywall is a number nobody sees
@@ -7590,25 +8997,85 @@ async function loadAccount() {
7590
8997
  // worth reading on the way to the dashboard rather than at a glance. Asked
7591
8998
  // for separately so a gateway having a bad day cannot keep the row from
7592
8999
  // saying who is signed in, which is the thing it is actually for.
7593
- let balance = null;
7594
- // The plan, from the same answer the balance comes out of. It was already in
7595
- // the response and thrown away, and it is the other half of the sentence:
7596
- // "0 credits" reads as a problem to someone on a plan and as a fact of life
7597
- // to someone who never subscribed, and the two want different next steps.
7598
- let plan = null;
7599
9000
  // Whether there is a shop at all. A self-hosted gateway sells nothing, and
7600
9001
  // an entry that opens a paywall with no plans in it is worse than no entry.
7601
9002
  let sellsCredits = false;
9003
+ // Whether the plan is live. This decides which billing action is primary in
9004
+ // the account card: Subscribe before a plan, Top up afterwards.
9005
+ let subscribed = false;
9006
+ // The team this machine bills to, once the gateway has said what it has.
9007
+ // A team the account was removed from comes back refused, and the line
9008
+ // says so rather than showing a number it could not read.
9009
+ let teamLine = null;
9010
+ // Whether the headline is a chosen team's rather than the person's, and
9011
+ // whether this person may pay for it. A team is bought and topped up on its
9012
+ // page on the site, and only by members with billing rights.
9013
+ let onTeam = false;
9014
+ let teamBilling = false;
9015
+ // The card's parts, kept apart rather than joined into sentences: whose
9016
+ // workspace (top left), its plan and whether that plan is live (top right),
9017
+ // and the balance under both. Joined with middle dots they read as one
9018
+ // shouted caption with nothing in it standing out.
9019
+ let workspaceName = null;
9020
+ let planName = null;
9021
+ let planStatus = null;
9022
+ let credits = null;
9023
+ knownTeams = Array.isArray(body.teams) ? body.teams : [];
9024
+ const teamNameOf = (id) => {
9025
+ const found = knownTeams.find((team) => team.id === id);
9026
+ return found ? found.name : t("account.teamUnnamed");
9027
+ };
7602
9028
  void getJson("/billing").then((state) => {
7603
9029
  if (!state || !state.signedIn || state.unavailable) return;
7604
9030
  sellsCredits = true;
7605
- if (state.balanceMicro !== undefined) {
7606
- balance = t("paywall.balance", { credits: creditsOf(state.balanceMicro) });
7607
- }
9031
+ // The plan, from the same answer the balance comes out of: "0 credits"
9032
+ // reads as a problem to someone on a plan and as a fact of life to someone
9033
+ // who never subscribed, and the two want different next steps.
7608
9034
  const sub = state.subscription;
7609
- plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
9035
+ subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
9036
+ // A plan's name as the catalogue spells it ("Team"), not its id ("team");
9037
+ // the id stands in only for a plan the catalogue no longer lists.
9038
+ const planLabel = (id) => {
9039
+ const found = (Array.isArray(state.plans) ? state.plans : []).find((one) => one && one.id === id);
9040
+ return (found && found.label) || id;
9041
+ };
9042
+ workspaceName = t("account.personal");
9043
+ planName = sub && sub.plan ? planLabel(sub.plan) : null;
9044
+ planStatus = sub && sub.plan ? sub.status : null;
9045
+ credits = state.balanceMicro !== undefined ? creditsOf(state.balanceMicro) : null;
9046
+ if (state.team && state.team.refused) {
9047
+ teamLine = t("account.teamRefused", { team: teamNameOf(state.team.id) });
9048
+ } else if (state.team) {
9049
+ // A chosen team is whose money the next call spends, so the card is the
9050
+ // team's alone: its balance and its plan, which decides Subscribe or Top
9051
+ // up. The person's own plan used to lead instead, and a member of a
9052
+ // paying team with no plan of their own was told "No subscription" and
9053
+ // offered one. Their own balance is not shown beside it — two balances in
9054
+ // one card left it unclear which the next call draws on — and choosing
9055
+ // Personal in this menu shows it.
9056
+ const name = teamNameOf(state.team.id);
9057
+ const teamSub = state.team.subscription;
9058
+ teamBilling = state.team.billing === true;
9059
+ subscribed = Boolean(teamSub && (teamSub.status === "active" || teamSub.status === "trialing"));
9060
+ workspaceName = name;
9061
+ planName = teamSub && teamSub.plan ? planLabel(teamSub.plan) : null;
9062
+ planStatus = teamSub && teamSub.plan ? teamSub.status : null;
9063
+ credits = creditsOf(state.team.balanceMicro || 0);
9064
+ onTeam = true;
9065
+ }
7610
9066
  });
7611
9067
 
9068
+ // Switch whose credits the next call spends. Said out loud, because the
9069
+ // only other sign is a different number in this menu next time it opens.
9070
+ const billTo = (id) => {
9071
+ closeRowMenu();
9072
+ void api("/team", { team: id }).then((result) => {
9073
+ if (!result || result.error) return void addNotice((result && result.error) || "could not switch", "err");
9074
+ addNotice(id ? t("account.usingTeam", { team: teamNameOf(id) }) : t("account.usingOwn"));
9075
+ void loadAccount();
9076
+ });
9077
+ };
9078
+
7612
9079
  row.addEventListener("click", (e) => {
7613
9080
  e.stopPropagation();
7614
9081
  if (menuRow === row) return closeRowMenu();
@@ -7617,36 +9084,139 @@ async function loadAccount() {
7617
9084
  // dim lines the size of a footnote, above the things to click — which is
7618
9085
  // backwards: the balance is why somebody opened this menu, and the plan
7619
9086
  // is what decides which of the entries below is the one they want.
7620
- if (balance || plan) {
7621
- const head = el("div", "menu-head");
7622
- if (balance) head.appendChild(el("div", "menu-head-main", balance));
7623
- if (plan) head.appendChild(el("div", "menu-head-sub", plan));
7624
- items.push(head);
7625
- }
7626
- // Buying, before the dashboard. Somebody who opened this menu after seeing
7627
- // "0 credits" two lines up wants the shop, and the dashboard is a longer
7628
- // way to the same place. It opens the paywall this app already has rather
7629
- // than a browser tab: the balance refreshes itself there, and the turn
7630
- // that could not be paid for is one click from being sent again.
7631
- if (sellsCredits) {
7632
- items.push(
7633
- menuItem(t("account.getCredits"), {
7634
- run: () => {
9087
+ if (workspaceName || teamLine) {
9088
+ const head = el("div", "menu-head account-card");
9089
+ if (workspaceName) {
9090
+ const top = el("div", "menu-head-top");
9091
+ top.appendChild(el("span", "menu-head-name", workspaceName));
9092
+ // The plan as a pill whose dot says whether it is live: green for a
9093
+ // plan that pays, amber for one whose payment failed, grey for none.
9094
+ // The word for the status is written only when it is not the ordinary
9095
+ // one — "Active" beside every paying plan is a word nobody reads.
9096
+ const live = planStatus === "active" || planStatus === "trialing";
9097
+ const pill = el("span", "plan-pill" + (live ? " ok" : planStatus === "past_due" ? " warn" : ""));
9098
+ pill.appendChild(el("i", "plan-pill-dot"));
9099
+ pill.appendChild(el("span", "plan-pill-name", planName || t("account.noPlan")));
9100
+ if (planName && planStatus && planStatus !== "active") {
9101
+ pill.appendChild(el("span", "plan-pill-status", t("account.status." + planStatus)));
9102
+ }
9103
+ if (planName && planStatus) pill.title = t("account.status." + planStatus);
9104
+ top.appendChild(pill);
9105
+ head.appendChild(top);
9106
+ }
9107
+ if (credits !== null) {
9108
+ const amount = el("div", "menu-head-amount");
9109
+ amount.appendChild(el("span", "menu-head-num", credits));
9110
+ amount.appendChild(el("span", "menu-head-unit", t("account.creditsUnit")));
9111
+ head.appendChild(amount);
9112
+ }
9113
+ // A team the account was removed from: said in place of a balance the
9114
+ // gateway would not give.
9115
+ if (teamLine) head.appendChild(el("div", "menu-head-sub", teamLine));
9116
+ if (sellsCredits && onTeam) {
9117
+ // One action, on the team page: that is where a team plan is bought
9118
+ // and a team balance topped up. Members without billing rights get
9119
+ // none — a button that can only fail is worse than its absence, and
9120
+ // the paywall already tells them to ask when the credits run out.
9121
+ if (teamBilling) {
9122
+ const actions = el("div", "menu-billing-actions");
9123
+ const go = el("button", "menu-billing-action primary", t(subscribed ? "paywall.topUp" : "paywall.subscribe"));
9124
+ go.type = "button";
9125
+ go.addEventListener("click", () => {
9126
+ closeRowMenu();
9127
+ if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
9128
+ });
9129
+ actions.appendChild(go);
9130
+ head.appendChild(actions);
9131
+ }
9132
+ } else if (sellsCredits) {
9133
+ const actions = el("div", "menu-billing-actions");
9134
+ if (!subscribed) {
9135
+ const subscribe = el("button", "menu-billing-action primary", t("paywall.subscribe"));
9136
+ subscribe.type = "button";
9137
+ subscribe.addEventListener("click", () => {
7635
9138
  closeRowMenu();
7636
9139
  openPaywall(true);
7637
- },
7638
- }),
7639
- );
9140
+ });
9141
+ actions.appendChild(subscribe);
9142
+ }
9143
+ const topUp = el("button", "menu-billing-action" + (subscribed ? " primary" : ""), t("paywall.topUp"));
9144
+ topUp.type = "button";
9145
+ topUp.disabled = !subscribed;
9146
+ if (!subscribed) topUp.title = t("paywall.needsPlan");
9147
+ topUp.addEventListener("click", () => {
9148
+ closeRowMenu();
9149
+ openPaywall(true);
9150
+ });
9151
+ actions.appendChild(topUp);
9152
+ head.appendChild(actions);
9153
+ }
9154
+ items.push(head);
7640
9155
  }
9156
+ // The dashboard straight under the balance it explains, and above the
9157
+ // workspace list, which is a choice rather than a destination.
7641
9158
  items.push(
7642
9159
  menuItem(t("account.dashboard"), {
7643
9160
  run: () => {
7644
9161
  closeRowMenu();
7645
- // window.open, so the desktop build hands this to the real browser
7646
- // rather than loading the site inside the window the agent runs in.
7647
9162
  if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
7648
9163
  },
7649
9164
  }),
9165
+ );
9166
+ // Whose credits the next call spends: Personal, or a team the account is
9167
+ // on, the current one ticked the way the model menu ticks its choice.
9168
+ // Only once there is a team to choose, since a list of one is no choice.
9169
+ if (knownTeams.length) {
9170
+ items.push(el("div", "menu-head menu-section", t("account.workspace")));
9171
+ const choice = (label, id) => {
9172
+ const chosen = (body.team || null) === id;
9173
+ const item = el("button", "menu-item" + (chosen ? " on" : ""));
9174
+ item.type = "button";
9175
+ item.appendChild(el("span", "tick", "\u2713"));
9176
+ item.appendChild(el("span", "menu-label", label));
9177
+ item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
9178
+ return item;
9179
+ };
9180
+ items.push(choice(t("account.personal"), null));
9181
+ knownTeams.forEach((team) => items.push(choice(team.name, team.id)));
9182
+ }
9183
+ // The way to a workspace at all, for everyone signed in: Create a team for an
9184
+ // account on none, Manage workspaces once there is one. Both are the team page
9185
+ // on the site, in the real browser, where a team is started, its people
9186
+ // invited and its seats bought.
9187
+ if (body.site) {
9188
+ items.push(
9189
+ menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
9190
+ run: () => {
9191
+ closeRowMenu();
9192
+ window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
9193
+ },
9194
+ }),
9195
+ );
9196
+ }
9197
+ // The two pages this menu had no way to reach, under the workspace rows
9198
+ // they belong beside: what a team costs and how to ask for the parts of it
9199
+ // that are not self-serve, and the way to reach a person at all. Both open
9200
+ // in the real browser like every other outbound item here — in the desktop
9201
+ // build the window handler routes them there rather than loading the site
9202
+ // inside the window the session lives in.
9203
+ if (body.site) {
9204
+ items.push(
9205
+ menuItem(t("account.enterprise"), {
9206
+ run: () => {
9207
+ closeRowMenu();
9208
+ window.open(body.site + "/" + LOCALE + "/enterprise", "_blank", "noopener");
9209
+ },
9210
+ }),
9211
+ menuItem(t("account.contact"), {
9212
+ run: () => {
9213
+ closeRowMenu();
9214
+ window.open(body.site + "/" + LOCALE + "/contact", "_blank", "noopener");
9215
+ },
9216
+ }),
9217
+ );
9218
+ }
9219
+ items.push(
7650
9220
  menuItem(t("account.download"), {
7651
9221
  run: () => {
7652
9222
  closeRowMenu();
@@ -7667,6 +9237,11 @@ async function loadAccount() {
7667
9237
  }),
7668
9238
  );
7669
9239
  openRowMenu(row, row, items);
9240
+ // As wide as the row it opens from, and placed again once it is: the
9241
+ // first placement measured the menu at its own width.
9242
+ rowMenu.classList.add("fit-anchor", "account-menu");
9243
+ rowMenu.style.width = row.getBoundingClientRect().width + "px";
9244
+ placeRowMenu(row);
7670
9245
  });
7671
9246
  host.appendChild(row);
7672
9247
  }
@@ -7953,6 +9528,48 @@ function openPaywall(buying) {
7953
9528
  return;
7954
9529
  }
7955
9530
 
9531
+ // A team is chosen, so the balance that ran out is the team's, and the
9532
+ // shop for it is on the site rather than here — only someone with billing
9533
+ // permission buys for the team, and they do it on the team page. What this screen
9534
+ // can offer is the team's number, who can fix it, and the way back to
9535
+ // spending one's own credits.
9536
+ if (state.team) {
9537
+ const teamName = (knownTeams.find((team) => team.id === state.team.id) || {}).name
9538
+ || t("account.teamUnnamed");
9539
+ if (state.team.refused) {
9540
+ body.appendChild(el("div", "cn-note", t("account.teamRefused", { team: teamName })));
9541
+ } else {
9542
+ const pooled = el("p", "pw-balance");
9543
+ pooled.appendChild(el("strong", null, creditsOf(state.team.balanceMicro || 0)));
9544
+ pooled.appendChild(el("span", "muted", t("paywall.teamUnit", { team: teamName })));
9545
+ body.appendChild(pooled);
9546
+ body.appendChild(
9547
+ el("div", "cn-note",
9548
+ t(state.team.billing ? "paywall.teamOwner" : "paywall.teamMember", { team: teamName })),
9549
+ );
9550
+ }
9551
+ const actions = el("div", "pw-plans");
9552
+ if (state.team.billing || state.team.refused) {
9553
+ const page = el("button", "pw-plan", t("paywall.teamPage"));
9554
+ page.type = "button";
9555
+ page.onclick = () => {
9556
+ window.open((site || "https://oharness.ai") + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
9557
+ };
9558
+ actions.appendChild(page);
9559
+ }
9560
+ const own = el("button", "pw-plan", t("account.useOwn"));
9561
+ own.type = "button";
9562
+ own.onclick = () => {
9563
+ void api("/team", { team: null }).then(() => {
9564
+ void loadAccount();
9565
+ void check();
9566
+ });
9567
+ };
9568
+ actions.appendChild(own);
9569
+ body.appendChild(actions);
9570
+ return;
9571
+ }
9572
+
7956
9573
  const sub = state.subscription;
7957
9574
  const subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
7958
9575
  body.appendChild(
@@ -7964,7 +9581,9 @@ function openPaywall(buying) {
7964
9581
  // checkout for it — so it is not a row. The account page button below is
7965
9582
  // where it lives.
7966
9583
  const packs = plans.filter((plan) => plan.kind === "topup");
7967
- const subs = plans.filter((plan) => plan.kind === "subscription");
9584
+ // The seat plan is a team's and is bought on the team page; on this
9585
+ // list it would be a rung this account cannot buy for itself.
9586
+ const subs = plans.filter((plan) => plan.kind === "subscription" && !plan.seat);
7968
9587
 
7969
9588
  if (!subs.length && !packs.length) {
7970
9589
  body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
@@ -8122,10 +9741,14 @@ function openPaywall(buying) {
8122
9741
  }
8123
9742
  // Credits on the account mean the turn can be sent again, whether they
8124
9743
  // arrived a second ago or were there the whole time. Both are the same
8125
- // sentence to whoever is looking at this.
8126
- if (state.signedIn && Number(state.balanceMicro || 0) > 0) {
9744
+ // sentence to whoever is looking at this. With a team chosen it is the
9745
+ // team's number that decides: the person's own credits are not what
9746
+ // the next call draws on.
9747
+ const pooled = state.team && !state.team.refused ? state.team : undefined;
9748
+ const spendable = pooled ? Number(pooled.balanceMicro || 0) : Number(state.balanceMicro || 0);
9749
+ if (state.signedIn && spendable > 0) {
8127
9750
  closePaywall();
8128
- return void addNotice(t("paywall.ready", { credits: creditsOf(state.balanceMicro) }));
9751
+ return void addNotice(t("paywall.ready", { credits: creditsOf(spendable) }));
8129
9752
  }
8130
9753
  render(state);
8131
9754
  });
@@ -8274,6 +9897,7 @@ async function refreshModelCount() {
8274
9897
  document.getElementById("nav-models").addEventListener("click", () => {
8275
9898
  setNav("nav-models");
8276
9899
  focusProvider = null;
9900
+ panelBody.replaceChildren(panelLoading());
8277
9901
  void showModels();
8278
9902
  });
8279
9903
 
@@ -8356,6 +9980,11 @@ async function showSettings() {
8356
9980
  const panel = el("div", "panel");
8357
9981
  panel.appendChild(el("h2", null, t("settings.title")));
8358
9982
 
9983
+ // Rows are grouped by what they are about. Each group is filled as the page
9984
+ // is built and drawn at the end under its heading, and only when it has rows.
9985
+ const groups = { workspace: el("div"), sync: el("div"), memory: el("div"), activity: el("div"), advanced: el("div") };
9986
+ let target = groups.workspace;
9987
+
8359
9988
  // A click on a button inside a summary toggles the disclosure as well as
8360
9989
  // running the button, so every action on a row gets this. Without it a
8361
9990
  // switch would flip and open the row underneath it, which reads as the page
@@ -8391,10 +10020,40 @@ async function showSettings() {
8391
10020
  if (long) body.appendChild(el("div", "mono small", value));
8392
10021
  if (hint) body.appendChild(el("div", "muted small", hint));
8393
10022
  row.appendChild(body);
8394
- panel.appendChild(row);
10023
+ target.appendChild(row);
8395
10024
  return body;
8396
10025
  };
8397
10026
 
10027
+ /**
10028
+ * What a door row shows when it is opened, read the first time it is.
10029
+ *
10030
+ * Every row here that leads to a page is opened by somebody who wants a
10031
+ * look before they decide to go there, and a row whose whole body was a
10032
+ * sentence and a button made them go there to find out whether it was worth
10033
+ * going. So each one carries the shape of its page: the figures, the first
10034
+ * few rules, the last few decisions.
10035
+ *
10036
+ * On first open, not on paint. Two of these walk every session on disk, and
10037
+ * the rows are on screen whether or not anybody cares — which is why the
10038
+ * totals were left off them in the first place.
10039
+ */
10040
+ const preview = (body, load) => {
10041
+ const box = el("div");
10042
+ body.appendChild(box);
10043
+ const row = body.parentNode;
10044
+ let loaded = false;
10045
+ row.addEventListener("toggle", () => {
10046
+ if (!row.open || loaded) return;
10047
+ loaded = true;
10048
+ box.appendChild(el("div", "muted small", t("preview.loading")));
10049
+ void load(box).then(
10050
+ () => {},
10051
+ (err) => { box.replaceChildren(el("div", "muted small", String(err && err.message || err))); },
10052
+ );
10053
+ });
10054
+ return box;
10055
+ };
10056
+
8398
10057
  // A labelled block inside an opened row: what the label names, printed as
8399
10058
  // typed. Used for the permission rules and the settings files, both of
8400
10059
  // which are lists someone reads once and closes again.
@@ -8436,64 +10095,140 @@ async function showSettings() {
8436
10095
  change.type = "button";
8437
10096
  change.addEventListener("click", openProjectPicker);
8438
10097
  field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
8439
- writesField(settings);
10098
+ permissionsField(settings);
8440
10099
 
8441
10100
  // A door, not a report: the numbers live on a page of their own, and the row
8442
10101
  // here carries no total of its own because working one out means walking
8443
10102
  // every session on disk — a cost this page would then pay on every paint,
8444
10103
  // for a figure most visits are not here for.
10104
+ target = groups.activity;
8445
10105
  const openUsage = el("button", "ghost", t("usage.open"));
8446
10106
  openUsage.type = "button";
8447
10107
  openUsage.addEventListener("click", () => {
8448
10108
  setNav("usage");
8449
10109
  void showUsage();
8450
10110
  });
8451
- field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
10111
+ const usageRow = field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
10112
+ preview(usageRow, async (box) => {
10113
+ const body = await getJson("/usage?range=30d");
10114
+ box.replaceChildren();
10115
+ if (body.error) return void box.appendChild(el("div", "muted small", body.error));
10116
+ const totals = body.totals || {};
10117
+ const sent = (totals.input || 0) + (totals.cacheRead || 0) + (totals.cacheWrite || 0);
10118
+ const moved = sent + (totals.output || 0);
10119
+ const facts = [
10120
+ "$" + (totals.costUsd || 0).toFixed(2),
10121
+ t("usage.tokens", { n: formatTokens(moved) }),
10122
+ ];
10123
+ if (totals.cacheRead) facts.push(t("usage.cached", { pct: Math.round((totals.cacheRead / sent) * 100) }));
10124
+ facts.push(totals.sessions === 1 ? t("usage.overSession") : t("usage.overSessions", { n: totals.sessions || 0 }));
10125
+ // The default window, said out loud: the figures on the page move with
10126
+ // its picker, and a preview that named no window would be read as all time.
10127
+ box.appendChild(el("div", "mono small", facts.join(" · ")));
10128
+ box.appendChild(el("div", "muted small", t("usage.range.30d")));
10129
+ });
8452
10130
 
8453
10131
  /*
8454
10132
  * Memory sits here rather than in the sidebar, and only when a plugin has
8455
10133
  * actually registered it: a row for a page that does not exist would be a
8456
10134
  * dead end, and the memory plugin can be switched off.
8457
10135
  */
10136
+ target = groups.memory;
8458
10137
  for (const page of pluginPages.values()) {
8459
10138
  const open = el("button", "ghost", t("settings.memoryOpen"));
8460
10139
  open.type = "button";
8461
10140
  open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
8462
- field(t("settings.memory"), "", t("settings.memoryHint"), open);
10141
+ const row = field(t("settings.memory"), "", t("settings.memoryHint"), open);
10142
+ const id = page.id;
10143
+ preview(row, async (box) => {
10144
+ const result = await getJson("/plugin-page?id=" + encodeURIComponent(id)
10145
+ + "&session=" + encodeURIComponent(activeId || ""));
10146
+ box.replaceChildren();
10147
+ if (result.error && !result.entries) {
10148
+ return void box.appendChild(el("div", "muted small", result.error));
10149
+ }
10150
+ // The same state the page opens with, worked out the same way — one
10151
+ // sentence rather than the card, because the card is the page.
10152
+ const state = !result.signedIn ? "local"
10153
+ : result.accountChanged ? "account"
10154
+ : !result.enabled ? "off"
10155
+ : result.syncing ? "syncing"
10156
+ : (result.errors && result.errors.length) || result.error ? "error" : "on";
10157
+ box.appendChild(el("div", "small", t("memory.state." + state)));
10158
+ const entries = result.entries || [];
10159
+ const project = entries.filter((entry) => entry.scope !== "user").length;
10160
+ box.appendChild(el("div", "muted small", result.userAvailable
10161
+ ? t("memory.countsScoped", { project, user: entries.length - project })
10162
+ : t("memory.counts", { count: entries.length })));
10163
+ });
8463
10164
  }
8464
10165
 
8465
- rulesField(settings.permissions || {});
10166
+ target = groups.activity;
8466
10167
  auditField(settings.audit || { on: true, source: "user" });
10168
+ target = groups.advanced;
8467
10169
  filesField(settings.settingsPaths);
8468
10170
 
8469
10171
  /**
8470
- * What runs, what stops to ask, and what is refused — one row, not four.
10172
+ * What runs, what stops to ask, what is refused, and where writes may land
10173
+ * — one row, and a page behind it.
8471
10174
  *
8472
- * Each kind used to get a card of its own, so a project with a handful of
8473
- * allow rules pushed every setting under it off the screen for a list that
8474
- * is read after something surprising happened and not before. The counts
8475
- * answer "is anything set here?" from the closed line; the rules themselves
8476
- * are one click down.
10175
+ * These were two rows called Rules and Writes, which is two names for one
10176
+ * subject: both are the permission policy, both are read after something
10177
+ * surprising happened, and neither is a thing anybody sets from here. The
10178
+ * row answers "is anything set?" from its closed line; the lists, and the
10179
+ * one editor among them, are on the page.
8477
10180
  */
8478
- function rulesField(rules) {
8479
- const kinds = ["allow", "ask", "deny"];
8480
- const summary = [];
8481
- for (const kind of kinds) {
10181
+ function permissionsField(settings) {
10182
+ const workspace = settings.workspace || {};
10183
+ const rules = settings.permissions || {};
10184
+ const roots = (workspace.writableRoots || []).length;
10185
+
10186
+ const summary = [workspace.mode || "—"];
10187
+ if (workspace.mode === "workspace-write") summary.push(roots + " roots");
10188
+ for (const kind of ["allow", "ask", "deny"]) {
8482
10189
  if (rules[kind] && rules[kind].length) summary.push(kind + " " + rules[kind].length);
8483
10190
  }
8484
- const session = rules.sessionAllow || [];
8485
- if (session.length) summary.push(t("settings.sessionApproved") + " " + session.length);
8486
- if (!summary.length) return;
10191
+ const session = (rules.sessionAllow || []).length;
10192
+ if (session) summary.push(t("settings.sessionApproved") + " " + session);
8487
10193
 
8488
- const body = field(t("settings.rules"), summary.join(" · "), t("settings.rulesHint"));
8489
- for (const kind of kinds) {
8490
- if (rules[kind] && rules[kind].length) body.appendChild(listing(kind, rules[kind].join("\n")));
8491
- }
8492
- if (session.length) {
10194
+ const open = el("button", "ghost", t("perms.open"));
10195
+ open.type = "button";
10196
+ open.addEventListener("click", () => { setNav("permissions"); void showPermissions(); });
10197
+ const body = field(t("perms.title"), summary.join(" · "), t("perms.hint"), open);
10198
+
10199
+ /*
10200
+ * No read behind this one: everything here came with the settings the row
10201
+ * was built from, so it is all free and is shown at once.
10202
+ *
10203
+ * The mode and the roots lead, because they are the part that is always
10204
+ * there. A project with no rules of its own is the common case, and this
10205
+ * row answered it with one sentence saying nothing was set — while the
10206
+ * page behind it had a mode and fifty-two directories to show.
10207
+ */
10208
+ const shown = 4;
10209
+ body.appendChild(el("div", "mono small", [
10210
+ workspace.mode || "—",
10211
+ workspace.mode === "workspace-write" ? t("perms.rootsCount", { n: roots }) : "",
10212
+ ].filter(Boolean).join(" · ")));
10213
+ const chosen = settings.configuredRoots || [];
10214
+ const rootList = chosen.length ? chosen : (workspace.writableRoots || []);
10215
+ if (workspace.mode === "workspace-write" && rootList.length) {
8493
10216
  body.appendChild(listing(
8494
- t("settings.sessionApproved") + " — " + t("settings.notPersisted"),
8495
- session.join("\n")));
10217
+ chosen.length ? t("perms.mine") : t("perms.writes"),
10218
+ rootList.slice(0, shown).join("\n")));
8496
10219
  }
10220
+
10221
+ let hidden = 0;
10222
+ let ruled = false;
10223
+ for (const kind of ["allow", "ask", "deny"]) {
10224
+ const list = rules[kind] || [];
10225
+ if (!list.length) continue;
10226
+ ruled = true;
10227
+ body.appendChild(listing(kind + " · " + list.length, list.slice(0, shown).join("\n")));
10228
+ hidden += Math.max(0, list.length - shown);
10229
+ }
10230
+ if (!ruled) body.appendChild(el("div", "muted small", t("perms.noRules")));
10231
+ if (hidden) body.appendChild(el("div", "muted small", t("perms.more", { n: hidden })));
8497
10232
  }
8498
10233
 
8499
10234
  /**
@@ -8513,10 +10248,30 @@ async function showSettings() {
8513
10248
  function auditField(audit) {
8514
10249
  const mine = audit.source !== "project";
8515
10250
 
10251
+ /*
10252
+ * A door beside the switch.
10253
+ *
10254
+ * The row answers the question it is passed on the way to something else
10255
+ * — is this on, and what did the session I am in just get allowed to do —
10256
+ * and the page behind it answers the one the trail is actually opened
10257
+ * for: when was this ever allowed, which is a question a single session
10258
+ * rarely holds the answer to. Leaving that to the export meant answering
10259
+ * it by reading JSON lines by hand.
10260
+ */
10261
+ const open = el("button", "ghost", t("settings.auditOpen"));
10262
+ open.type = "button";
10263
+ open.addEventListener("click", () => {
10264
+ auditScope = "session";
10265
+ auditFilter = "all";
10266
+ setNav("audit");
10267
+ void showAudit();
10268
+ });
10269
+
8516
10270
  const body = field(
8517
10271
  t("settings.audit"),
8518
10272
  t(audit.on ? "settings.auditOnValue" : "settings.auditOffValue"),
8519
10273
  mine ? t(audit.on ? "settings.auditOn" : "settings.auditOff") : t("settings.auditProject"),
10274
+ open,
8520
10275
  toggle(audit.on, t("settings.audit"), (next) => {
8521
10276
  void api("/audit", { enabled: next }).then((result) => {
8522
10277
  if (result && result.error) addNotice(result.error, "err");
@@ -8532,8 +10287,40 @@ async function showSettings() {
8532
10287
  body.appendChild(el("div", "muted small", t("settings.auditFile", { path: audit.file })));
8533
10288
  }
8534
10289
 
8535
- const details = el("div");
8536
- body.appendChild(details);
10290
+ preview(body, async (box) => {
10291
+ const result = await getJson("/audit");
10292
+ box.replaceChildren();
10293
+ if (result && result.error) return void box.appendChild(panelError(result.error));
10294
+ const rows = auditRows(result.entries);
10295
+ if (!rows.length) {
10296
+ box.appendChild(el("div", "muted small", t("settings.auditEmpty")));
10297
+ /*
10298
+ * An empty session is not an empty machine, and the row said only the
10299
+ * first of those — which reads as a trail that records nothing.
10300
+ *
10301
+ * The wider read happens only here, in the one case where the cheap
10302
+ * one came back with nothing: it is a file read per session, and
10303
+ * paying it to decorate a row that already had something to show
10304
+ * would be the cost this preview is lazy to avoid.
10305
+ */
10306
+ const everywhere = await getJson("/audit?session=all");
10307
+ if (everywhere.error) return;
10308
+ const all = auditRows(everywhere.entries);
10309
+ if (all.length) {
10310
+ box.appendChild(el("div", "muted small", t("audit.elsewhere", { n: all.length })));
10311
+ }
10312
+ return;
10313
+ }
10314
+ // The most recent handful, and a count of what is behind them. The row
10315
+ // used to print two hundred lines into a box inside a disclosure inside
10316
+ // a scrolling page, and stop dead at the two hundredth with a number
10317
+ // and nowhere to go; the rest of them are what the page is for.
10318
+ box.appendChild(auditList(rows.slice(0, AUDIT_PREVIEW), false));
10319
+ if (rows.length > AUDIT_PREVIEW) {
10320
+ box.appendChild(el("div", "muted small",
10321
+ t("settings.auditMore", { n: rows.length - AUDIT_PREVIEW })));
10322
+ }
10323
+ });
8537
10324
 
8538
10325
  /*
8539
10326
  * Taking the record away with you.
@@ -8548,76 +10335,7 @@ async function showSettings() {
8548
10335
  * "send me the audit" means, and building it out of one export per session
8549
10336
  * is not a thing anybody should be asked to do by hand.
8550
10337
  */
8551
- const exports = el("div", "row-actions");
8552
- for (const scope of ["session", "all"]) {
8553
- const link = el("a", "ghost", t("settings.auditExport" + (scope === "all" ? "All" : "")));
8554
- link.href = q("/audit/export?session=" + encodeURIComponent(scope === "all" ? "all" : activeId));
8555
- // The server names it; this only tells the browser to save rather than
8556
- // to navigate, which it would otherwise do with a JSON content type.
8557
- link.setAttribute("download", "");
8558
- exports.appendChild(link);
8559
- }
8560
- body.appendChild(exports);
8561
-
8562
- // Read on first open, not on every settings paint: the trail is a file
8563
- // read per session and the row is on screen whether or not anyone cares.
8564
- const row = body.parentNode;
8565
- let loaded = false;
8566
- row.addEventListener("toggle", () => {
8567
- if (!row.open || loaded) return;
8568
- loaded = true;
8569
- void getJson("/audit").then((result) => renderTrail(details, result));
8570
- });
8571
- }
8572
-
8573
- /**
8574
- * The trail, newest first, one line per question.
8575
- *
8576
- * A decision and the answer that closed it are one row, not two: they are
8577
- * one event as far as anybody reading this is concerned, and the pair is
8578
- * exactly what the old single-line format could not express — it recorded
8579
- * that something was asked and never what was answered.
8580
- */
8581
- function renderTrail(target, body) {
8582
- target.replaceChildren();
8583
- if (body && body.error) return void target.appendChild(panelError(body.error));
8584
-
8585
- const entries = (body && body.entries) || [];
8586
- if (!entries.length) {
8587
- return void target.appendChild(el("div", "muted small", t("settings.auditEmpty")));
8588
- }
8589
-
8590
- const answers = new Map();
8591
- for (const entry of entries) if (entry.kind === "answer") answers.set(entry.id, entry);
8592
-
8593
- const list = el("div", "mono small scroll-box");
8594
-
8595
- const decisions = entries.filter((entry) => entry.kind === "decision").reverse();
8596
- for (const entry of decisions.slice(0, 200)) {
8597
- const line = el("div", "trail-line");
8598
- const when = new Date(entry.at).toLocaleTimeString();
8599
- const answer = answers.get(entry.id);
8600
- // The answer replaces the behaviour it answered rather than following
8601
- // it: "ask → allowed" is one fact, and printing both reads as two.
8602
- const outcome = answer ? answer.outcome : entry.behavior;
8603
- const mark = el("span", outcome === "denied" || outcome === "unavailable" ? "err" : null,
8604
- outcome);
8605
- line.appendChild(el("span", "muted", when + " "));
8606
- line.appendChild(mark);
8607
- line.appendChild(document.createTextNode(" " + (entry.principal || entry.tool)));
8608
- const notes = [];
8609
- if (entry.rule) notes.push(entry.rule);
8610
- if (answer && answer.remembered) notes.push("remembered " + answer.remembered);
8611
- // An ask with no answer: the process stopped while the question was up.
8612
- if (entry.behavior === "ask" && !answer) notes.push("unanswered");
8613
- if (notes.length) line.appendChild(el("span", "muted", " " + notes.join(" · ")));
8614
- list.appendChild(line);
8615
- }
8616
- target.appendChild(list);
8617
- if (decisions.length > 200) {
8618
- target.appendChild(el("div", "muted small",
8619
- t("settings.auditMore", { n: decisions.length - 200 })));
8620
- }
10338
+ body.appendChild(auditExports(activeId));
8621
10339
  }
8622
10340
 
8623
10341
  /**
@@ -8651,72 +10369,9 @@ async function showSettings() {
8651
10369
  * rebuilt at launch, so a control over it would be a control that silently
8652
10370
  * reverts.
8653
10371
  */
8654
- function writesField(settings) {
8655
- const workspace = settings.workspace || {};
8656
- if (workspace.mode !== "workspace-write") {
8657
- return void field(t("settings.writes"), workspace.mode || "—");
8658
- }
8659
-
8660
- const all = workspace.writableRoots || [];
8661
- const chosen = settings.configuredRoots || [];
8662
- const derived = all.filter((root) => chosen.indexOf(root) < 0);
8663
-
8664
- const body = field(
8665
- t("settings.writes"),
8666
- workspace.mode + " · " + all.length + " roots",
8667
- all.length + " writable roots — a policy gate on tool arguments, not a sandbox: a shell command can still reach any path you can.",
8668
- );
8669
-
8670
- // Yours, and editable. A textarea rather than a list with buttons because
8671
- // the thing being edited is a handful of paths, and typing one is what
8672
- // someone came here to do.
8673
- const mine = el("div", "setting-list");
8674
- mine.appendChild(el("div", "muted small", "Roots you configured"));
8675
- body.appendChild(mine);
8676
-
8677
- const box = el("textarea", "boxed");
8678
- box.value = chosen.join("\n");
8679
- box.rows = Math.max(2, chosen.length + 1);
8680
- box.spellcheck = false;
8681
- box.style.width = "100%";
8682
- box.placeholder = "One absolute path per line";
8683
- mine.appendChild(box);
8684
-
8685
- const save = el("button", "ghost", "Save roots");
8686
- save.type = "button";
8687
- save.addEventListener("click", () => {
8688
- save.disabled = true;
8689
- const roots = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
8690
- void api("/settings/writable-roots", { roots }).then((result) => {
8691
- save.disabled = false;
8692
- if (result && result.error) return void addNotice(result.error, "err");
8693
- // Said out loud, because the row above will go on showing the old
8694
- // list until then and someone would reasonably read that as a failure.
8695
- addNotice("saved — takes effect the next time the app starts");
8696
- });
8697
- });
8698
- mine.appendChild(save);
8699
-
8700
- if (derived.length) {
8701
- const theirs = el("div", "setting-list");
8702
- // "more" only reads right when there were some above it. With nothing
8703
- // configured — the common case — every one of them is derived.
8704
- theirs.appendChild(el("div", "muted small",
8705
- (chosen.length ? derived.length + " more come" : "All " + derived.length + " come") +
8706
- " from installed plugins and are rebuilt at launch:"));
8707
- // Scrolls in its own box rather than growing the page. Expanded, forty
8708
- // cache paths is the same wall of text this row was collapsed to escape
8709
- // — the click should reveal the list, not restore the problem.
8710
- const list = el("div", "mono small scroll-box");
8711
- list.style.opacity = ".7";
8712
- list.textContent = derived.join("\n");
8713
- theirs.appendChild(list);
8714
- body.appendChild(theirs);
8715
- }
8716
- }
8717
-
8718
10372
  // Sync, above analytics, because it is the one on this page that decides
8719
10373
  // where the work goes rather than whether a counter is incremented.
10374
+ target = groups.sync;
8720
10375
  const sync = await getJson("/sync");
8721
10376
  if (sync && !sync.error) {
8722
10377
  // Three states, not two. Off and "on but signed out" both send nothing,
@@ -8778,6 +10433,7 @@ async function showSettings() {
8778
10433
  // Last, and only when this build could actually send something. A switch
8779
10434
  // that does nothing is worse than no switch: it invites someone to believe
8780
10435
  // they turned something off that was never on.
10436
+ target = groups.activity;
8781
10437
  const analytics = await getJson("/analytics");
8782
10438
  if (analytics && !analytics.error && analytics.available) {
8783
10439
  field(
@@ -8789,6 +10445,17 @@ async function showSettings() {
8789
10445
  }),
8790
10446
  );
8791
10447
  }
10448
+ const sections = [
10449
+ ["settings.section.workspace", [groups.workspace]],
10450
+ ["settings.section.sync", [groups.sync, groups.memory]],
10451
+ ["settings.section.activity", [groups.activity]],
10452
+ ["settings.section.advanced", [groups.advanced]],
10453
+ ];
10454
+ for (const [title, parts] of sections) {
10455
+ if (!parts.some((part) => part.childNodes.length)) continue;
10456
+ panel.appendChild(el("div", "section-title", t(title)));
10457
+ for (const part of parts) panel.appendChild(part);
10458
+ }
8792
10459
  panelBody.appendChild(panel);
8793
10460
  }
8794
10461
 
@@ -8805,8 +10472,672 @@ async function showSettings() {
8805
10472
  * counted twice, nothing survives a session that was deleted, and a run
8806
10473
  * started from the terminal is in the totals the same as one started here.
8807
10474
  */
10475
+ /**
10476
+ * The permission policy, on a page.
10477
+ *
10478
+ * Everything the Settings row summarises: the mode, the rules in each of the
10479
+ * three kinds, what was approved for this session only, and the writable
10480
+ * roots — the one editable thing here, which is why the page exists rather
10481
+ * than a taller row. A textarea inside a disclosure inside a scrolling page
10482
+ * was an editor nobody could see the whole of while typing in it.
10483
+ */
10484
+ async function showPermissions() {
10485
+ const settings = await getJson("/settings");
10486
+ if (shownPanel !== "permissions") return;
10487
+ panelBody.replaceChildren();
10488
+ if (settings.error) return void panelBody.appendChild(panelError(settings.error));
10489
+
10490
+ const workspace = settings.workspace || {};
10491
+ const rules = settings.permissions || {};
10492
+
10493
+ const panel = el("div", "panel");
10494
+ const head = el("div", "row-head");
10495
+ head.appendChild(el("h2", null, t("perms.title")));
10496
+ const back = el("button", "ghost", t("usage.back"));
10497
+ back.type = "button";
10498
+ back.addEventListener("click", () => {
10499
+ setNav("nav-settings");
10500
+ void showSettings();
10501
+ });
10502
+ head.appendChild(back);
10503
+ panel.appendChild(head);
10504
+ panelBody.appendChild(panel);
10505
+
10506
+ // A labelled block: what the label names, printed as typed.
10507
+ const listing = (label, text, faint) => {
10508
+ const group = el("div", "setting-list");
10509
+ group.appendChild(el("div", "muted small", label));
10510
+ const list = el("div", "mono small" + (faint ? " scroll-box" : ""));
10511
+ if (faint) list.style.opacity = ".7";
10512
+ list.textContent = text;
10513
+ group.appendChild(list);
10514
+ return group;
10515
+ };
10516
+
10517
+ const card = (title) => {
10518
+ panel.appendChild(el("h3", "section-title", title));
10519
+ const box = el("section", "row-card perms-card");
10520
+ panel.appendChild(box);
10521
+ return box;
10522
+ };
10523
+
10524
+ // The mode first, because it decides what the rules below are exceptions to.
10525
+ const modeCard = card(t("perms.mode"));
10526
+ modeCard.appendChild(el("div", "mono", workspace.mode || "—"));
10527
+
10528
+ const rulesCard = card(t("settings.rules"));
10529
+ rulesCard.appendChild(el("div", "muted small", t("settings.rulesHint")));
10530
+ let any = false;
10531
+ for (const kind of ["allow", "ask", "deny"]) {
10532
+ if (!rules[kind] || !rules[kind].length) continue;
10533
+ any = true;
10534
+ rulesCard.appendChild(listing(kind + " · " + rules[kind].length, rules[kind].join("\n")));
10535
+ }
10536
+ const session = rules.sessionAllow || [];
10537
+ if (session.length) {
10538
+ any = true;
10539
+ rulesCard.appendChild(listing(
10540
+ t("settings.sessionApproved") + " — " + t("settings.notPersisted"),
10541
+ session.join("\n")));
10542
+ }
10543
+ if (!any) rulesCard.appendChild(el("div", "muted small", t("perms.noRules")));
10544
+
10545
+ // The roots, and only where they mean anything: in every other mode the
10546
+ // list is not consulted, and an editor for it would be a control that does
10547
+ // nothing until a setting somewhere else changes.
10548
+ if (workspace.mode === "workspace-write") {
10549
+ const all = workspace.writableRoots || [];
10550
+ const chosen = settings.configuredRoots || [];
10551
+ const derived = all.filter((root) => chosen.indexOf(root) < 0);
10552
+
10553
+ const writes = card(t("perms.writes"));
10554
+ writes.appendChild(el("div", "muted small", t("perms.writesHint", { n: all.length })));
10555
+
10556
+ // Yours, and editable. A textarea rather than a list with buttons because
10557
+ // the thing being edited is a handful of paths, and typing one is what
10558
+ // someone came here to do.
10559
+ const mine = el("div", "setting-list");
10560
+ mine.appendChild(el("div", "muted small", t("perms.mine")));
10561
+ writes.appendChild(mine);
10562
+
10563
+ const box = el("textarea", "boxed");
10564
+ box.value = chosen.join("\n");
10565
+ box.rows = Math.max(3, chosen.length + 1);
10566
+ box.spellcheck = false;
10567
+ box.style.width = "100%";
10568
+ box.placeholder = t("perms.placeholder");
10569
+ mine.appendChild(box);
10570
+
10571
+ const save = el("button", "ghost", t("perms.save"));
10572
+ save.type = "button";
10573
+ save.addEventListener("click", () => {
10574
+ save.disabled = true;
10575
+ const roots = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
10576
+ void api("/settings/writable-roots", { roots }).then((result) => {
10577
+ save.disabled = false;
10578
+ if (result && result.error) return void addNotice(result.error, "err");
10579
+ // Said out loud, because the list above will go on showing the old
10580
+ // one until then and someone would reasonably read that as a failure.
10581
+ addNotice(t("perms.saved"));
10582
+ });
10583
+ });
10584
+ mine.appendChild(save);
10585
+
10586
+ if (derived.length) {
10587
+ // "more" only reads right when there were some above it. With nothing
10588
+ // configured — the common case — every one of them is derived.
10589
+ writes.appendChild(listing(
10590
+ chosen.length
10591
+ ? t("perms.derivedMore", { n: derived.length })
10592
+ : t("perms.derivedAll", { n: derived.length }),
10593
+ derived.join("\n"),
10594
+ true));
10595
+ }
10596
+ }
10597
+ }
10598
+
10599
+ /*
10600
+ * ---- the permission trail -------------------------------------------------
10601
+ *
10602
+ * Two readers of one record. The Settings row shows the last few decisions of
10603
+ * the session someone is in; the page shows every session on this machine,
10604
+ * filtered by how each decision ended. They share the pairing and the line
10605
+ * below rather than each having a copy — the pairing is the part that was
10606
+ * wrong before it existed, and one of them would have kept being wrong.
10607
+ */
10608
+
10609
+ /** How much of the trail the Settings row shows before it points at the page. */
10610
+ const AUDIT_PREVIEW = 12;
10611
+
10612
+ /** How many lines the page draws at once, and adds each time it is asked for more. */
10613
+ const AUDIT_PAGE = 200;
10614
+
10615
+ /**
10616
+ * Decisions newest first, each carrying the answer that closed it.
10617
+ *
10618
+ * A decision and its answer are one event to anybody reading this, and were
10619
+ * two lines on disk because that is when each was known. Keyed by session as
10620
+ * well as id: ids are unique within the file they were written in, and the
10621
+ * page reads every file at once.
10622
+ */
10623
+ function auditRows(entries) {
10624
+ const list = entries || [];
10625
+ const key = (entry) => (entry.session || "") + "/" + entry.id;
10626
+ const answers = new Map();
10627
+ for (const entry of list) if (entry.kind === "answer") answers.set(key(entry), entry);
10628
+ return list
10629
+ .filter((entry) => entry.kind === "decision")
10630
+ .map((entry) => ({ entry, answer: answers.get(key(entry)) || null }))
10631
+ .sort((a, b) => b.entry.at - a.entry.at);
10632
+ }
10633
+
10634
+ /**
10635
+ * How a decision ended.
10636
+ *
10637
+ * The answer replaces the behaviour it answered rather than following it:
10638
+ * "ask → allowed" is one fact, and printing both reads as two.
10639
+ */
10640
+ function auditOutcome(row) {
10641
+ return row.answer ? row.answer.outcome : row.entry.behavior;
10642
+ }
10643
+
10644
+ /** Which of the page's filters a row belongs to. */
10645
+ function auditKind(row) {
10646
+ if (row.entry.behavior === "ask" && !row.answer) return "unanswered";
10647
+ const outcome = auditOutcome(row);
10648
+ if (outcome === "denied" || outcome === "deny" || outcome === "unavailable") return "denied";
10649
+ return "allowed";
10650
+ }
10651
+
10652
+ /**
10653
+ * The trail as lines, newest first.
10654
+ *
10655
+ * withDate, because the two readers ask over different spans: within one
10656
+ * session the time of day places an entry, and across every session on disk
10657
+ * it does not place anything at all.
10658
+ */
10659
+ function auditList(rows, withDate) {
10660
+ const list = el("div", "mono small audit-list");
10661
+ for (const row of rows) {
10662
+ const entry = row.entry;
10663
+ const line = el("div", "trail-line");
10664
+ const at = new Date(entry.at);
10665
+ const when = withDate
10666
+ ? at.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + at.toLocaleTimeString()
10667
+ : at.toLocaleTimeString();
10668
+ const outcome = auditOutcome(row);
10669
+ const mark = el("span", auditKind(row) === "denied" ? "err" : null, outcome);
10670
+ line.appendChild(el("span", "muted", when + " "));
10671
+ line.appendChild(mark);
10672
+ line.appendChild(document.createTextNode(" " + (entry.principal || entry.tool)));
10673
+ const notes = [];
10674
+ if (entry.rule) notes.push(entry.rule);
10675
+ if (row.answer && row.answer.remembered) notes.push("remembered " + row.answer.remembered);
10676
+ // An ask with no answer: the process stopped while the question was up.
10677
+ if (entry.behavior === "ask" && !row.answer) notes.push("unanswered");
10678
+ if (notes.length) line.appendChild(el("span", "muted", " " + notes.join(" · ")));
10679
+ list.appendChild(line);
10680
+ }
10681
+ return list;
10682
+ }
10683
+
10684
+ /**
10685
+ * The two downloads, for whichever scope is on screen.
10686
+ *
10687
+ * Plain links rather than buttons that fetch and build a blob: the server
10688
+ * already names the file and hands it over, and a download the page assembles
10689
+ * in memory is one the browser cannot resume, cannot stream, and shows in no
10690
+ * download list.
10691
+ */
10692
+ function auditExports(session) {
10693
+ const exports = el("div", "row-actions");
10694
+ for (const scope of ["session", "all"]) {
10695
+ const link = el("a", "ghost", t("settings.auditExport" + (scope === "all" ? "All" : "")));
10696
+ link.href = q("/audit/export?session=" + encodeURIComponent(scope === "all" ? "all" : session));
10697
+ // The server names it; this only tells the browser to save rather than
10698
+ // to navigate, which it would otherwise do with a JSON content type.
10699
+ link.setAttribute("download", "");
10700
+ exports.appendChild(link);
10701
+ }
10702
+ return exports;
10703
+ }
10704
+
10705
+ /** Which slice of the trail the page is showing, for this page load. */
10706
+ let auditScope = "session";
10707
+ let auditFilter = "all";
10708
+
10709
+ /**
10710
+ * Every permission decision, one page of them at a time.
10711
+ *
10712
+ * Its own page for the same reason usage has one: what it holds outgrew the
10713
+ * row it was reached from. The scope is the part that could not live there at
10714
+ * all — the row reads the session it is in, and "when did I ever allow this"
10715
+ * is a question about the others.
10716
+ */
10717
+ async function showAudit() {
10718
+ const scope = auditScope;
10719
+ const body = await getJson(
10720
+ "/audit" + (scope === "all" ? "?session=all" : activeId ? "?session=" + encodeURIComponent(activeId) : ""),
10721
+ );
10722
+ if (shownPanel !== "audit") return;
10723
+ panelBody.replaceChildren();
10724
+ if (body.error) return void panelBody.appendChild(panelError(body.error));
10725
+
10726
+ const panel = el("div", "panel audit");
10727
+ const head = el("div", "row-head");
10728
+ head.appendChild(el("h2", null, t("settings.audit")));
10729
+ // Back to where this was opened from: the sidebar row it lit is already
10730
+ // showing as active, so without this the only exit is a click that looks
10731
+ // like a no-op.
10732
+ const back = el("button", "ghost", t("usage.back"));
10733
+ back.type = "button";
10734
+ back.addEventListener("click", () => {
10735
+ setNav("nav-settings");
10736
+ void showSettings();
10737
+ });
10738
+ head.appendChild(back);
10739
+ panel.appendChild(head);
10740
+
10741
+ /*
10742
+ * Two segmented controls, and they are not the same kind of control.
10743
+ *
10744
+ * The scope reloads, because it is a different read; the filter does not,
10745
+ * because everything it can show is already here. Keeping them side by side
10746
+ * anyway: which decisions and whose are one question asked in two halves,
10747
+ * and splitting them across the page would hide that changing one changes
10748
+ * what the other is choosing from.
10749
+ */
10750
+ const controls = el("div", "audit-controls");
10751
+ // chosen is read at click time, not closed over: the filter changes this
10752
+ // control in place, and a guard against the value it was built with would
10753
+ // refuse to go back to the one it opened on.
10754
+ const seg = (options, chosen, onPick) => {
10755
+ const box = el("div", "seg");
10756
+ for (const option of options) {
10757
+ const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""), option.label);
10758
+ button.type = "button";
10759
+ button.addEventListener("click", () => {
10760
+ if (option.id === chosen()) return;
10761
+ onPick(option.id);
10762
+ });
10763
+ box.appendChild(button);
10764
+ }
10765
+ return box;
10766
+ };
10767
+ controls.appendChild(seg(
10768
+ [
10769
+ { id: "session", label: t("audit.scope.session") },
10770
+ { id: "all", label: t("audit.scope.all") },
10771
+ ],
10772
+ () => scope,
10773
+ (id) => { auditScope = id; void showAudit(); },
10774
+ ));
10775
+ const filters = ["all", "allowed", "denied", "unanswered"];
10776
+ const filterSeg = seg(
10777
+ filters.map((id) => ({ id, label: t("audit.filter." + id) })),
10778
+ () => auditFilter,
10779
+ (id) => {
10780
+ auditFilter = id;
10781
+ // Back to the top of the list: a filter changed under a list someone
10782
+ // had paged five hundred lines into is a different list.
10783
+ shown = AUDIT_PAGE;
10784
+ paint();
10785
+ },
10786
+ );
10787
+ controls.appendChild(filterSeg);
10788
+ panel.appendChild(controls);
10789
+
10790
+ const rows = auditRows(body.entries);
10791
+ const count = el("div", "muted small");
10792
+ panel.appendChild(count);
10793
+ const listBox = el("div");
10794
+ panel.appendChild(listBox);
10795
+ const foot = el("div");
10796
+ panel.appendChild(foot);
10797
+
10798
+ // Said out loud rather than left to a total that quietly stops short: a
10799
+ // machine with more sessions than one read returns is one where "nothing
10800
+ // was ever denied" would otherwise be a claim this page cannot make.
10801
+ if (body.more) panel.appendChild(el("div", "muted small", t("audit.capped")));
10802
+ panel.appendChild(auditExports(scope === "all" ? "all" : activeId));
10803
+ panelBody.appendChild(panel);
10804
+
10805
+ // How far down the list has been asked to go. Reset by a repaint rather
10806
+ // than kept across one: a filter that changed under a list scrolled to its
10807
+ // five-hundredth line is a different list.
10808
+ let shown = AUDIT_PAGE;
10809
+ paint();
10810
+
10811
+ function paint() {
10812
+ const picked = auditFilter === "all" ? rows : rows.filter((row) => auditKind(row) === auditFilter);
10813
+ // Only the filter is repainted in place; changing the scope is a reload,
10814
+ // and the control comes back with it.
10815
+ filterSeg.querySelectorAll(".seg-item").forEach((button, index) => {
10816
+ button.classList.toggle("on", filters[index] === auditFilter);
10817
+ });
10818
+
10819
+ count.textContent = [
10820
+ picked.length === 1 ? t("audit.countOne") : t("audit.count", { n: picked.length }),
10821
+ scope === "all" && body.sessions ? t("audit.acrossSessions", { n: body.sessions }) : "",
10822
+ ].filter(Boolean).join(" · ");
10823
+
10824
+ listBox.replaceChildren();
10825
+ foot.replaceChildren();
10826
+ if (!rows.length) {
10827
+ return void listBox.appendChild(el("div", "muted small",
10828
+ t(scope === "all" ? "audit.noneAll" : "settings.auditEmpty")));
10829
+ }
10830
+ if (!picked.length) {
10831
+ return void listBox.appendChild(el("div", "muted small", t("audit.noMatch")));
10832
+ }
10833
+ listBox.appendChild(auditList(picked.slice(0, shown), scope === "all"));
10834
+ if (picked.length > shown) {
10835
+ const more = el("button", "ghost", t("audit.showMore"));
10836
+ more.type = "button";
10837
+ more.addEventListener("click", () => {
10838
+ shown += AUDIT_PAGE;
10839
+ paint();
10840
+ });
10841
+ foot.appendChild(more);
10842
+ }
10843
+ }
10844
+ }
10845
+
8808
10846
  /** The window the usage page is asking about, for this page load. */
8809
10847
  let usageRange = "30d";
10848
+ /**
10849
+ * The first and last day of a picked window, once one has been picked.
10850
+ *
10851
+ * Kept as UTC day keys rather than as a pair of timestamps: they are what the
10852
+ * server is asked for, what it echoes back, and what the calendar below reads
10853
+ * — and a window that means one thing on the wire and another on screen is
10854
+ * the single failure a date picker cannot recover from.
10855
+ */
10856
+ let usageFrom = null;
10857
+ let usageTo = null;
10858
+
10859
+ const DAY_MS = 86400000;
10860
+ /**
10861
+ * The longest window that may be picked, matching the site's own picker.
10862
+ *
10863
+ * A year of daily columns is already hairs wide; past it the walk is every
10864
+ * session on disk for a curve that says nothing the presets do not.
10865
+ */
10866
+ const MAX_CUSTOM_DAYS = 366;
10867
+ const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
10868
+ const dayMs = (key) => Date.parse(key + "T00:00:00Z");
10869
+ /** The first UTC day of the month holding ms. */
10870
+ const monthOf = (ms) => Date.UTC(new Date(ms).getUTCFullYear(), new Date(ms).getUTCMonth(), 1);
10871
+ const shiftMonth = (first, by) =>
10872
+ Date.UTC(new Date(first).getUTCFullYear(), new Date(first).getUTCMonth() + by, 1);
10873
+
10874
+ /**
10875
+ * A range of UTC days, picked on a calendar of our own.
10876
+ *
10877
+ * The same component the site's usage page uses, down to the class names —
10878
+ * not the browser's date field, which looks different in every browser,
10879
+ * cannot show a range, and draws an outlined box that belongs to no design
10880
+ * here. Click a first day, then a last one; while the second is being chosen
10881
+ * the range follows the pointer. Days after max cannot be picked, nor a
10882
+ * second day more than maxDays from the first.
10883
+ *
10884
+ * Nothing is applied until Apply: closing the panel, clicking away from it or
10885
+ * pressing Escape leaves the page showing the window it was already showing,
10886
+ * because the days inside it are a draft until then.
10887
+ */
10888
+ function dateRangePicker(options) {
10889
+ const root = el("span", "drp");
10890
+ const maxMs = dayMs(options.max);
10891
+ const maxDays = options.maxDays;
10892
+
10893
+ let start = dayMs(options.from);
10894
+ let end = dayMs(options.to);
10895
+ let hover = null;
10896
+ let month = monthOf(dayMs(options.to));
10897
+ let open = false;
10898
+ let pop = null;
10899
+
10900
+ const reset = () => {
10901
+ start = dayMs(options.from);
10902
+ end = dayMs(options.to);
10903
+ hover = null;
10904
+ month = monthOf(dayMs(options.to));
10905
+ };
10906
+
10907
+ const monthName = new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" });
10908
+ const short = new Intl.DateTimeFormat(LOCALE, { month: "short", day: "numeric", timeZone: "UTC" });
10909
+ const shortYear = new Intl.DateTimeFormat(LOCALE, {
10910
+ month: "short", day: "numeric", year: "numeric", timeZone: "UTC",
10911
+ });
10912
+ // Sunday-first where the reader's language puts it there, Monday-first
10913
+ // everywhere else.
10914
+ const firstWeekday = /^(en|ja|ko)$/.test(LOCALE) ? 0 : 1;
10915
+ const weekdayName = new Intl.DateTimeFormat(LOCALE, { weekday: "narrow", timeZone: "UTC" });
10916
+ // 2023-01-01 was a Sunday.
10917
+ const weekdays = Array.from({ length: 7 }, (_, index) =>
10918
+ weekdayName.format(Date.UTC(2023, 0, 1 + ((firstWeekday + index) % 7))));
10919
+
10920
+ const span = (a, b) => {
10921
+ const n = Math.round((b - a) / DAY_MS) + 1;
10922
+ return n === 1 ? t("usage.range.oneDay") : t("usage.range.days", { n });
10923
+ };
10924
+ const rangeText = (a, b) =>
10925
+ a === b ? shortYear.format(a)
10926
+ : new Date(a).getUTCFullYear() === new Date(b).getUTCFullYear()
10927
+ ? short.format(a) + " – " + shortYear.format(b)
10928
+ : shortYear.format(a) + " – " + shortYear.format(b);
10929
+
10930
+ const trigger = el("button", "drp-trigger");
10931
+ trigger.type = "button";
10932
+ trigger.setAttribute("aria-haspopup", "dialog");
10933
+ const paintTrigger = () => {
10934
+ trigger.replaceChildren(svgIcon("calendar"));
10935
+ trigger.appendChild(document.createTextNode(options.active
10936
+ ? rangeText(dayMs(options.from), dayMs(options.to))
10937
+ : t("usage.range.custom")));
10938
+ trigger.setAttribute("aria-pressed", options.active ? "true" : "false");
10939
+ trigger.setAttribute("aria-expanded", open ? "true" : "false");
10940
+ trigger.classList.toggle("on", Boolean(options.active));
10941
+ };
10942
+ root.appendChild(trigger);
10943
+
10944
+ /*
10945
+ * The cells, kept so a hover can repaint the range without rebuilding them.
10946
+ *
10947
+ * The structure is built once per month shown; choosing and hovering only
10948
+ * toggle classes on what is already there. Rebuilding the grid on every
10949
+ * mouseenter replaced the button under the pointer, which fired mouseenter
10950
+ * on its replacement — a rebuild loop that swallowed the click meant to
10951
+ * close the range, so picking a second day never took.
10952
+ */
10953
+ let cells = [];
10954
+ let foot = null;
10955
+
10956
+ const pick = (day) => {
10957
+ if (end === null) {
10958
+ const first = Math.min(day, start);
10959
+ const last = Math.max(day, start);
10960
+ start = first;
10961
+ end = last;
10962
+ hover = null;
10963
+ } else {
10964
+ start = day;
10965
+ end = null;
10966
+ }
10967
+ paintRange();
10968
+ };
10969
+
10970
+ const disabled = (day) =>
10971
+ day > maxMs || (end === null && Math.abs(day - start) > (maxDays - 1) * DAY_MS);
10972
+
10973
+ /** The chosen range, and what the footer says about it. Classes only. */
10974
+ function paintRange() {
10975
+ // While the second day is being chosen, the range runs to the pointer.
10976
+ const lastShown = end === null ? hover : end;
10977
+ const low = lastShown === null ? start : Math.min(start, lastShown);
10978
+ const high = lastShown === null ? start : Math.max(start, lastShown);
10979
+ for (const cell of cells) {
10980
+ const at = cell.at;
10981
+ const edgeStart = at === low;
10982
+ const edgeEnd = at === high;
10983
+ cell.button.classList.toggle("is-inside", at > low && at < high);
10984
+ cell.button.classList.toggle("is-edge", edgeStart || edgeEnd);
10985
+ cell.button.classList.toggle("is-start", edgeStart && high > low);
10986
+ cell.button.classList.toggle("is-end", edgeEnd && high > low);
10987
+ cell.button.disabled = disabled(at);
10988
+ if (edgeStart || edgeEnd) cell.button.setAttribute("aria-current", "date");
10989
+ else cell.button.removeAttribute("aria-current");
10990
+ }
10991
+ if (!foot) return;
10992
+ foot.replaceChildren();
10993
+ const summary = el("span", "drp-summary");
10994
+ if (end === null) summary.appendChild(document.createTextNode(t("usage.range.pickEnd")));
10995
+ else {
10996
+ summary.appendChild(el("strong", null, rangeText(start, end)));
10997
+ summary.appendChild(el("span", "muted", " · " + span(start, end)));
10998
+ }
10999
+ foot.appendChild(summary);
11000
+
11001
+ const actions = el("span", "drp-actions");
11002
+ const cancel = el("button", "drp-cancel", t("usage.range.cancel"));
11003
+ cancel.type = "button";
11004
+ cancel.addEventListener("click", () => close());
11005
+ const apply = el("button", "drp-apply", t("usage.range.apply"));
11006
+ apply.type = "button";
11007
+ if (end === null) apply.disabled = true;
11008
+ else {
11009
+ apply.addEventListener("click", () => {
11010
+ const from = dayKey(start);
11011
+ const to = dayKey(end);
11012
+ close();
11013
+ options.onApply(from, to);
11014
+ });
11015
+ }
11016
+ actions.appendChild(cancel);
11017
+ actions.appendChild(apply);
11018
+ foot.appendChild(actions);
11019
+ }
11020
+
11021
+ const renderMonth = (first, className) => {
11022
+ const box = el("div", "drp-month " + className);
11023
+ box.appendChild(el("div", "drp-month-name", monthName.format(first)));
11024
+ const grid = el("div", "drp-grid");
11025
+ grid.setAttribute("role", "grid");
11026
+ for (const name of weekdays) {
11027
+ const cell = el("span", "drp-weekday", name);
11028
+ cell.setAttribute("aria-hidden", "true");
11029
+ grid.appendChild(cell);
11030
+ }
11031
+ const date = new Date(first);
11032
+ const year = date.getUTCFullYear();
11033
+ const index = date.getUTCMonth();
11034
+ const length = new Date(Date.UTC(year, index + 1, 0)).getUTCDate();
11035
+ const lead = (date.getUTCDay() - firstWeekday + 7) % 7;
11036
+ for (let i = 0; i < lead; i += 1) grid.appendChild(el("span"));
11037
+ for (let day = 1; day <= length; day += 1) {
11038
+ const at = Date.UTC(year, index, day);
11039
+ const button = el("button", "drp-day" + (at === maxMs ? " is-today" : ""));
11040
+ button.type = "button";
11041
+ button.appendChild(el("span", null, String(day)));
11042
+ button.setAttribute("aria-label", shortYear.format(at));
11043
+ button.addEventListener("click", () => { if (!button.disabled) pick(at); });
11044
+ button.addEventListener("mouseenter", () => {
11045
+ if (end !== null || button.disabled) return;
11046
+ hover = at;
11047
+ paintRange();
11048
+ });
11049
+ cells.push({ at, button });
11050
+ grid.appendChild(button);
11051
+ }
11052
+ box.appendChild(grid);
11053
+ return box;
11054
+ };
11055
+
11056
+ function paintPop() {
11057
+ if (!pop) return;
11058
+ pop.replaceChildren();
11059
+ cells = [];
11060
+ const nav = el("div", "drp-nav");
11061
+ const back = el("button", "drp-arrow");
11062
+ back.type = "button";
11063
+ back.appendChild(svgIcon("chevronLeft"));
11064
+ back.setAttribute("aria-label", t("usage.range.previous"));
11065
+ back.addEventListener("click", () => { month = shiftMonth(month, -1); paintPop(); });
11066
+ const forward = el("button", "drp-arrow");
11067
+ forward.type = "button";
11068
+ forward.appendChild(svgIcon("chevronRight"));
11069
+ forward.setAttribute("aria-label", t("usage.range.next"));
11070
+ if (month >= monthOf(maxMs)) forward.disabled = true;
11071
+ else forward.addEventListener("click", () => { month = shiftMonth(month, 1); paintPop(); });
11072
+ nav.appendChild(back);
11073
+ nav.appendChild(forward);
11074
+ pop.appendChild(nav);
11075
+
11076
+ const months = el("div", "drp-months");
11077
+ months.appendChild(renderMonth(shiftMonth(month, -1), "is-first"));
11078
+ months.appendChild(renderMonth(month, "is-second"));
11079
+ pop.appendChild(months);
11080
+
11081
+ foot = el("div", "drp-foot");
11082
+ pop.appendChild(foot);
11083
+ paintRange();
11084
+ }
11085
+
11086
+ /*
11087
+ * Kept inside the window.
11088
+ *
11089
+ * The trigger sits near the left of a panel that can be narrower than two
11090
+ * months, and the pop is anchored to it — so on a window this app is
11091
+ * routinely used at, Apply was past the right edge of the screen and the
11092
+ * calendar had no way to say yes. Measured rather than guessed at: the
11093
+ * width depends on the locale's month names.
11094
+ */
11095
+ function fit() {
11096
+ // The narrow layout centres the panel on the screen, so there is nothing
11097
+ // to nudge; a measured offset there would drag it back off the edge.
11098
+ if (!pop || matchMedia("(max-width:640px)").matches) return;
11099
+ pop.style.left = "0px";
11100
+ const box = pop.getBoundingClientRect();
11101
+ const over = box.right - (document.documentElement.clientWidth - 12);
11102
+ if (over > 0) pop.style.left = -Math.min(over, Math.max(box.left - 12, 0)) + "px";
11103
+ }
11104
+
11105
+ // Bound while the panel is up and taken down with it: a click-away handler
11106
+ // that outlives its panel is a page that reacts to clicks on nothing.
11107
+ const away = (event) => { if (!root.contains(event.target)) close(); };
11108
+ const escape = (event) => { if (event.key === "Escape") close(); };
11109
+
11110
+ function close() {
11111
+ if (!open) return;
11112
+ open = false;
11113
+ document.removeEventListener("pointerdown", away);
11114
+ document.removeEventListener("keydown", escape);
11115
+ if (pop) pop.remove();
11116
+ pop = null;
11117
+ // Back to the window the page is showing, so reopening does not resume a
11118
+ // half-made choice that was abandoned.
11119
+ reset();
11120
+ paintTrigger();
11121
+ }
11122
+
11123
+ trigger.addEventListener("click", () => {
11124
+ if (open) return void close();
11125
+ open = true;
11126
+ reset();
11127
+ pop = el("div", "drp-pop");
11128
+ pop.setAttribute("role", "dialog");
11129
+ pop.setAttribute("aria-label", t("usage.range.custom"));
11130
+ root.appendChild(pop);
11131
+ paintPop();
11132
+ fit();
11133
+ paintTrigger();
11134
+ document.addEventListener("pointerdown", away);
11135
+ document.addEventListener("keydown", escape);
11136
+ });
11137
+
11138
+ paintTrigger();
11139
+ return root;
11140
+ }
8810
11141
 
8811
11142
  /**
8812
11143
  * What was spent over time, one line per model.
@@ -8972,7 +11303,13 @@ function usageChart(body, models) {
8972
11303
  }
8973
11304
 
8974
11305
  async function showUsage() {
8975
- const body = await getJson("/usage?range=" + encodeURIComponent(usageRange));
11306
+ // The days ride along only when they are the window being asked for; the
11307
+ // server ignores them otherwise, and a preset request that carried a stale
11308
+ // pair would be one keystroke from answering for the wrong fortnight.
11309
+ const asked = usageRange === "custom" && usageFrom && usageTo
11310
+ ? "/usage?range=custom&from=" + encodeURIComponent(usageFrom) + "&to=" + encodeURIComponent(usageTo)
11311
+ : "/usage?range=" + encodeURIComponent(usageRange);
11312
+ const body = await getJson(asked);
8976
11313
  panelBody.replaceChildren();
8977
11314
  if (body.error) return void panelBody.appendChild(panelError(body.error));
8978
11315
 
@@ -9002,6 +11339,13 @@ async function showUsage() {
9002
11339
  * figures above it did not would be lying about one of them.
9003
11340
  */
9004
11341
  const picked = body.range || "30d";
11342
+ // What the server says the window is, which is not always what was asked
11343
+ // for: a picked range is clamped to today and to a year, and the calendar
11344
+ // has to open on the days the figures beside it are actually counting.
11345
+ const today = new Date().toISOString().slice(0, 10);
11346
+ const shownFrom = body.from || today;
11347
+ const shownTo = body.to || today;
11348
+ const windowRow = el("div", "usage-window");
9005
11349
  const ranges = el("div", "seg");
9006
11350
  for (const id of ["7d", "30d", "90d", "all"]) {
9007
11351
  const button = el("button", "seg-item" + (id === picked ? " on" : ""), t("usage.range." + id));
@@ -9013,7 +11357,31 @@ async function showUsage() {
9013
11357
  });
9014
11358
  ranges.appendChild(button);
9015
11359
  }
9016
- panel.appendChild(ranges);
11360
+ windowRow.appendChild(ranges);
11361
+ /*
11362
+ * A window the presets cannot express.
11363
+ *
11364
+ * Beside them rather than as a fifth item on the same control: the presets
11365
+ * are one click each and this one opens a calendar, and a segmented control
11366
+ * whose last segment behaves differently from the other four is a control
11367
+ * that has to be learned. It shows the days it is showing once it is the
11368
+ * chosen window, so the page never has a range on it that nothing on screen
11369
+ * names.
11370
+ */
11371
+ windowRow.appendChild(dateRangePicker({
11372
+ from: shownFrom,
11373
+ to: shownTo,
11374
+ max: today,
11375
+ maxDays: MAX_CUSTOM_DAYS,
11376
+ active: picked === "custom",
11377
+ onApply: (from, to) => {
11378
+ usageRange = "custom";
11379
+ usageFrom = from;
11380
+ usageTo = to;
11381
+ void showUsage();
11382
+ },
11383
+ }));
11384
+ panel.appendChild(windowRow);
9017
11385
 
9018
11386
  // Prompt tokens only. The cache share is a share of what was sent, and
9019
11387
  // nothing that was generated could have come from a cache.
@@ -9348,52 +11716,259 @@ async function showMemoryPage(id) {
9348
11716
  if (result.error && !result.entries) return void panelBody.appendChild(panelError(result.error));
9349
11717
  const panel = el("div", "panel memory-panel"); panelBody.appendChild(panel);
9350
11718
  panel.appendChild(el("h2", "", t("memory.title")));
9351
- panel.appendChild(el("p", "muted", t("memory.intro")));
9352
- panel.appendChild(el("p", "mono small memory-path", result.location));
9353
- 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"));
9354
- panel.appendChild(status);
9355
- if (result.lastSync) panel.appendChild(el("p", "muted small", t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) })));
9356
- if (result.error) panel.appendChild(panelError(result.error));
11719
+ /*
11720
+ * One card for what a visit is for: is sync working, how many notes there
11721
+ * are, and what to do next. The state is said once, in words, with a line
11722
+ * on what it means; an error sits inside it rather than in a separate
11723
+ * "could not load" box, which read as the page itself being broken. The
11724
+ * same message from both scopes is shown once.
11725
+ */
11726
+ const failures = [];
11727
+ for (const failure of (result.errors && result.errors.length ? result.errors : result.error ? [{ message: result.error }] : [])) {
11728
+ const same = failures.find((one) => memoryText(one) === memoryText(failure));
11729
+ if (same) same.scope = "both"; else failures.push({ ...failure });
11730
+ }
11731
+ const needsSignIn = !result.signedIn || failures.some((one) => one.code === "signInFirst" || one.code === "signInAgain");
11732
+ const state = !result.signedIn ? "local" : result.accountChanged ? "account" : !result.enabled ? "off" : result.syncing ? "syncing" : failures.length ? "error" : "on";
11733
+ const summaryCard = el("section", "row-card memory-summary");
11734
+ panel.appendChild(summaryCard);
11735
+ const status = el("div", "memory-state", t("memory.state." + state));
11736
+ summaryCard.appendChild(status);
11737
+ summaryCard.appendChild(el("p", "muted small", t("memory.stateHint." + state)));
11738
+ for (const failure of failures) {
11739
+ const text = memoryText(failure);
11740
+ const message = !result.userAvailable || !failure.scope || failure.scope === "both" ? text
11741
+ : failure.scope === "user" ? t("memory.errorUser", { message: text })
11742
+ : t("memory.errorProject", { message: text });
11743
+ summaryCard.appendChild(el("p", "small memory-error", message));
11744
+ }
11745
+ const facts = [];
11746
+ const projectCount = result.entries.filter((entry) => entry.scope !== "user").length;
11747
+ facts.push(result.userAvailable
11748
+ ? t("memory.countsScoped", { project: projectCount, user: result.entries.length - projectCount })
11749
+ : t("memory.counts", { count: result.entries.length }));
11750
+ // The ceiling is only news when it is close.
11751
+ if (result.limits && result.entries.length > result.limits.maxNotes * 0.8) facts.push(t("memory.usage", { count: result.entries.length, max: result.limits.maxNotes }));
11752
+ if (result.lastSync) facts.push(t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) }));
11753
+ summaryCard.appendChild(el("p", "muted small", facts.join(" · ")));
11754
+ if (result.omitted) summaryCard.appendChild(el("p", "muted small", t("memory.omitted", { count: result.omitted })));
11755
+ if (result.quota && result.quota.tier === "free" && result.quota.maxNotes) {
11756
+ summaryCard.appendChild(el("p", "muted small", t("memory.tierFree", { notes: result.quota.maxNotes, kb: Math.max(1, Math.round((result.quota.maxNoteBytes || 0) / 1024)) })));
11757
+ }
11758
+ // Where the files are, and how sync behaves, is looked up once; folded away by default.
11759
+ const about = el("details", "memory-about small");
11760
+ about.appendChild(el("summary", "muted", t("memory.aboutStorage")));
11761
+ about.appendChild(el("p", "muted", t("memory.intro")));
11762
+ if (result.locations && result.locations.user) {
11763
+ about.appendChild(el("p", "muted small", t("memory.projectLocation")));
11764
+ about.appendChild(el("p", "mono small memory-path", result.locations.project));
11765
+ about.appendChild(el("p", "muted small", t("memory.userLocation")));
11766
+ about.appendChild(el("p", "mono small memory-path", result.locations.user));
11767
+ } else about.appendChild(el("p", "mono small memory-path", result.location));
11768
+ panel.appendChild(about);
11769
+ for (const pull of result.recent || []) {
11770
+ const details = el("details", "memory-recent small");
11771
+ details.appendChild(el("summary", "", t("memory.recentPulled", {
11772
+ when: new Date(pull.at).toLocaleString(LOCALE), created: pull.created.length, updated: pull.updated.length, deleted: pull.deleted.length, merged: (pull.merged || []).length,
11773
+ })));
11774
+ for (const [key, notes] of [["memory.recentCreated", pull.created], ["memory.recentUpdated", pull.updated], ["memory.recentDeleted", pull.deleted], ["memory.recentMerged", pull.merged || []]]) {
11775
+ for (const note of notes) details.appendChild(el("div", "muted", t(key, { summary: note.summary })));
11776
+ }
11777
+ panel.appendChild(details);
11778
+ }
9357
11779
  const act = async (input) => {
9358
11780
  try {
9359
11781
  const reply = await api("/plugin-page", { page: id, session, input }, 120000);
9360
- if (reply.error) throw new Error(reply.error);
11782
+ if (reply.error) throw Object.assign(new Error(reply.error), { code: reply.code, params: reply.params });
9361
11783
  await showMemoryPage(id);
9362
11784
  } catch (error) {
9363
11785
  if (activeId === session && shownPanel === "nav-plugin-" + id) {
9364
11786
  await showMemoryPage(id);
9365
- panelBody.appendChild(panelError(error.message || t("memory.failed")));
11787
+ // In the summary card, in the page's language. The generic "could not
11788
+ // load" box read as the page being broken, and its hint about the
11789
+ // server's URL token has nothing to do with a refused action.
11790
+ const note = el("p", "small memory-error", memoryText(error));
11791
+ const card = panelBody.querySelector(".memory-summary");
11792
+ (card || panelBody).appendChild(note);
11793
+ note.scrollIntoView({ block: "nearest" });
9366
11794
  }
9367
11795
  }
9368
11796
  };
9369
- const controls = el("div", ""); controls.style.cssText = "display:flex;gap:12px;align-items:center;margin:16px 0";
11797
+ const controls = el("div", "memory-actions");
11798
+ if (needsSignIn) {
11799
+ const signIn = el("button", "signin-site", t("memory.signIn")); signIn.type = "button";
11800
+ signIn.onclick = async () => { const account = await getJson("/account"); openConnect(account && account.provider); };
11801
+ controls.appendChild(signIn);
11802
+ }
9370
11803
  const toggle = el("button", "ghost", result.enabled ? t("memory.turnOff") : t("memory.turnOn"));
9371
11804
  toggle.setAttribute("role", "switch"); toggle.setAttribute("aria-label", t("memory.switchLabel")); toggle.setAttribute("aria-checked", String(result.enabled));
9372
11805
  toggle.disabled = !result.enabled && (!result.signedIn || result.accountChanged);
9373
11806
  toggle.onclick = async () => { toggle.disabled = true; await act({ action: "enable", enabled: !result.enabled }); toggle.disabled = false; };
9374
- controls.appendChild(toggle);
11807
+ // Signed out, "turn off cloud sync" is a switch for something that is not
11808
+ // happening; the sign-in button is the action that means something.
11809
+ if (result.signedIn || !result.enabled) controls.appendChild(toggle);
9375
11810
  if (result.signedIn) {
9376
11811
  const sync = el("button", "ghost", t("memory.syncNow")); sync.disabled = !result.ready || result.syncing;
9377
11812
  sync.onclick = async () => { sync.disabled = true; status.textContent = t("memory.syncing"); await act({ action: "sync" }); sync.disabled = false; };
9378
11813
  controls.appendChild(sync);
9379
11814
  }
9380
11815
  const refresh = el("button", "ghost", t("memory.refresh")); refresh.onclick = () => { void showMemoryPage(id); }; controls.appendChild(refresh);
9381
- panel.appendChild(controls);
11816
+ summaryCard.appendChild(controls);
11817
+ // A conflict is what sync could not combine: both sides changed the same part.
11818
+ // All three versions are shown, and whatever is set aside is backed up.
9382
11819
  for (const conflict of result.conflicts || []) {
9383
- const row = el("section", "row-card"); row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
9384
- for (const [label, note] of [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]]) {
9385
- 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);
11820
+ const row = el("section", "row-card memory-conflict");
11821
+ row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
11822
+ const fields = (conflict.fields || []).map((field) => t("memory.field." + field)).join(t("memory.fieldSeparator"));
11823
+ if (fields) row.appendChild(el("p", "muted small", t("memory.conflictFields", { fields })));
11824
+ const versions = el("div", "memory-versions");
11825
+ const shown = [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]];
11826
+ if (conflict.base) shown.unshift([t("memory.base"), conflict.base]);
11827
+ for (const [label, note] of shown) {
11828
+ const column = el("div", "memory-version");
11829
+ column.appendChild(el("h4", "small", label));
11830
+ const text = el("pre", "small", note ? note.summary + "\n\n" + note.body + (note.tags && note.tags.length ? "\n\n" + note.tags.join(", ") : "") : t("memory.deleted"));
11831
+ text.style.whiteSpace = "pre-wrap";
11832
+ column.appendChild(text);
11833
+ versions.appendChild(column);
9386
11834
  }
9387
- for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")]]) {
9388
- const button = el("button", "ghost", label); button.onclick = () => { void act({ action: "resolve", id: conflict.id, choice }); }; row.appendChild(button);
11835
+ row.appendChild(versions);
11836
+ const actions = el("div", "memory-actions");
11837
+ for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")], ["both", t("memory.keepBoth")]]) {
11838
+ if (choice === "both" && !(conflict.local && conflict.cloud)) continue;
11839
+ const button = el("button", "ghost", label); button.type = "button";
11840
+ button.onclick = () => { void act({ action: "resolve", scope: conflict.scope, id: conflict.id, choice }); };
11841
+ actions.appendChild(button);
11842
+ }
11843
+ const byHand = el("button", "ghost", t("memory.mergeByHand")); byHand.type = "button";
11844
+ actions.appendChild(byHand);
11845
+ row.appendChild(actions);
11846
+ const start = conflict.local || conflict.cloud || { summary: "", body: "", tags: [] };
11847
+ const form = el("form", "memory-editor"); form.hidden = true;
11848
+ const summary = el("input", ""); summary.value = start.summary; summary.required = true; summary.setAttribute("aria-label", t("memory.summaryLabel"));
11849
+ const body = el("textarea", ""); body.value = start.body; body.rows = 8; body.required = true; body.setAttribute("aria-label", t("memory.bodyLabel"));
11850
+ const tags = el("input", ""); tags.value = (start.tags || []).join(", "); tags.setAttribute("aria-label", t("memory.tagsLabel"));
11851
+ for (const control of [summary, body, tags]) {
11852
+ control.style.cssText = "display:block;width:100%;box-sizing:border-box;margin:8px 0";
11853
+ form.appendChild(control);
9389
11854
  }
11855
+ const save = el("button", "ghost", t("memory.mergeSave")); save.type = "submit";
11856
+ form.appendChild(save);
11857
+ form.onsubmit = async (event) => {
11858
+ event.preventDefault(); save.disabled = true;
11859
+ await act({ action: "resolve", scope: conflict.scope, id: conflict.id, choice: "manual", note: {
11860
+ summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean), type: start.type || "",
11861
+ } });
11862
+ save.disabled = false;
11863
+ };
11864
+ byHand.onclick = () => { form.hidden = !form.hidden; };
11865
+ row.appendChild(form);
9390
11866
  panel.appendChild(row);
9391
11867
  }
11868
+ // Tidying: what a review of the notes found, and a merge waiting to be applied.
11869
+ const labelOf = (scope, id) => {
11870
+ const found = result.entries.find((entry) => entry.id === id && entry.scope === scope);
11871
+ return found ? found.summary : id;
11872
+ };
11873
+ if (result.proposal) {
11874
+ const proposal = result.proposal;
11875
+ const card = el("section", "row-card memory-conflict");
11876
+ card.appendChild(el("strong", "", t("memory.mergeTitle", { count: proposal.ids.length })));
11877
+ card.appendChild(el("p", "muted small", proposal.source === "model" ? t("memory.mergeFromModel") : t("memory.mergeByHandHint")));
11878
+ const sources = el("ul", "muted small");
11879
+ proposal.ids.forEach((id, index) => {
11880
+ sources.appendChild(el("li", "", (index === 0 ? t("memory.mergeKeeps") : t("memory.mergeRemoves")) + labelOf(proposal.scope, id)));
11881
+ });
11882
+ card.appendChild(sources);
11883
+ const form = el("form", "memory-editor");
11884
+ const summary = el("input", ""); summary.value = proposal.draft.summary; summary.required = true; summary.setAttribute("aria-label", t("memory.summaryLabel"));
11885
+ const body = el("textarea", ""); body.value = proposal.draft.body; body.rows = 10; body.required = true; body.setAttribute("aria-label", t("memory.bodyLabel"));
11886
+ const tags = el("input", ""); tags.value = (proposal.draft.tags || []).join(", "); tags.setAttribute("aria-label", t("memory.tagsLabel"));
11887
+ for (const control of [summary, body, tags]) {
11888
+ control.style.cssText = "display:block;width:100%;box-sizing:border-box;margin:8px 0";
11889
+ form.appendChild(control);
11890
+ }
11891
+ const mergeActions = el("div", "memory-actions");
11892
+ const apply = el("button", "ghost", t("memory.mergeApply")); apply.type = "submit"; mergeActions.appendChild(apply);
11893
+ const cancel = el("button", "ghost", t("memory.mergeCancel")); cancel.type = "button";
11894
+ cancel.onclick = () => { void act({ action: "cancelMerge" }); };
11895
+ mergeActions.appendChild(cancel);
11896
+ form.appendChild(mergeActions);
11897
+ form.onsubmit = async (event) => {
11898
+ event.preventDefault();
11899
+ if (!confirm(t("memory.mergeConfirm"))) return;
11900
+ apply.disabled = true;
11901
+ await act({ action: "merge", note: { summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean), type: proposal.draft.type || "" } });
11902
+ apply.disabled = false;
11903
+ };
11904
+ card.appendChild(form);
11905
+ panel.appendChild(card);
11906
+ }
11907
+ const findings = result.review || [];
11908
+ const consolidate = result.consolidate || {};
11909
+ const tidy = el("details", "row-card memory-note");
11910
+ const tidyHead = el("summary", "");
11911
+ tidyHead.appendChild(el("span", "chev"));
11912
+ tidyHead.appendChild(el("span", "memory-note-name", t("memory.review", { count: result.reviewTotal || findings.length })));
11913
+ tidy.appendChild(tidyHead);
11914
+ tidy.appendChild(el("p", "muted small", consolidate.auto ? t("memory.reviewAutoOn") : t("memory.reviewIntro")));
11915
+ if (consolidate.lastAuto) {
11916
+ tidy.appendChild(el("p", "muted small", t("memory.reviewLastAuto", { when: new Date(consolidate.lastAuto.at).toLocaleString(LOCALE), count: consolidate.lastAuto.merged.length })));
11917
+ }
11918
+ if (!findings.length) tidy.appendChild(el("p", "muted", t("memory.reviewNone")));
11919
+ for (const finding of findings) {
11920
+ const row = el("div", "memory-backup");
11921
+ const text = el("div", "memory-backup-text");
11922
+ text.appendChild(el("div", "", t("memory.finding." + finding.kind, { count: finding.ids.length })));
11923
+ const names = finding.ids.map((id) => labelOf(finding.scope, id)).join(" / ");
11924
+ text.appendChild(el("div", "muted small", names + (finding.detail && finding.detail.length ? " · " + finding.detail.join(", ") : "")));
11925
+ row.appendChild(text);
11926
+ const buttons = el("div", "memory-actions");
11927
+ if (finding.kind === "duplicate") {
11928
+ if (consolidate.modelAvailable) {
11929
+ const suggest = el("button", "ghost", t("memory.mergeSuggest")); suggest.type = "button";
11930
+ suggest.onclick = async () => {
11931
+ suggest.disabled = true; suggest.textContent = t("memory.mergeThinking");
11932
+ await act({ action: "propose", scope: finding.scope, ids: finding.ids, model: true });
11933
+ };
11934
+ buttons.appendChild(suggest);
11935
+ }
11936
+ const manual = el("button", "ghost", t("memory.mergeManual")); manual.type = "button";
11937
+ manual.onclick = () => { void act({ action: "propose", scope: finding.scope, ids: finding.ids, model: false }); };
11938
+ buttons.appendChild(manual);
11939
+ } else {
11940
+ const open = el("button", "ghost", t("memory.reviewOpen")); open.type = "button";
11941
+ open.onclick = () => {
11942
+ search.value = ""; draw();
11943
+ const found = list.querySelector('[data-note-id="' + CSS.escape(finding.ids[0]) + '"]');
11944
+ if (found) { found.open = true; found.scrollIntoView({ block: "center" }); }
11945
+ };
11946
+ buttons.appendChild(open);
11947
+ }
11948
+ const dismiss = el("button", "ghost", t("memory.reviewDismiss")); dismiss.type = "button";
11949
+ dismiss.onclick = () => { void act({ action: "dismiss", key: finding.key }); };
11950
+ buttons.appendChild(dismiss);
11951
+ row.appendChild(buttons);
11952
+ tidy.appendChild(row);
11953
+ }
11954
+ // Nothing to tidy is not worth a row.
11955
+ if (findings.length || consolidate.lastAuto) panel.appendChild(tidy);
9392
11956
  const editor = (note, extra) => {
9393
11957
  const form = el("form", "memory-editor"), summary = el("input", ""), body = el("textarea", ""), tags = el("input", "");
9394
11958
  summary.placeholder = t("memory.summaryPlaceholder"); summary.value = note?.summary || ""; summary.required = true;
9395
11959
  body.placeholder = t("memory.bodyPlaceholder"); body.value = note?.body || ""; body.rows = 6; body.required = true;
9396
11960
  tags.placeholder = t("memory.tagsPlaceholder"); tags.value = (note?.tags || []).join(", ");
11961
+ const type = el("select", "");
11962
+ for (const [value, label] of [["", t("memory.typeNone")], ["user", t("memory.typeUser")], ["feedback", t("memory.typeFeedback")], ["project", t("memory.typeProject")], ["reference", t("memory.typeReference")]]) {
11963
+ const option = el("option", "", label); option.value = value; type.appendChild(option);
11964
+ }
11965
+ type.value = note?.type || "";
11966
+ const scope = el("select", "");
11967
+ for (const [value, label] of [["project", t("memory.scopeProject")], ["user", t("memory.scopeUser")]]) {
11968
+ const option = el("option", "", label); option.value = value; scope.appendChild(option);
11969
+ }
11970
+ // A note moves between scopes by being written anew there; editing keeps it where it is.
11971
+ scope.value = note?.scope || "project"; scope.disabled = !!note;
9397
11972
  /*
9398
11973
  * A label each, and a line saying what the field is for.
9399
11974
  *
@@ -9417,6 +11992,8 @@ async function showMemoryPage(id) {
9417
11992
  form.appendChild(labelled(summary, t("memory.summaryLabel"), t("memory.summaryHint")));
9418
11993
  form.appendChild(labelled(body, t("memory.bodyLabel"), t("memory.bodyHint")));
9419
11994
  form.appendChild(labelled(tags, t("memory.tagsLabel"), t("memory.tagsHint")));
11995
+ form.appendChild(labelled(type, t("memory.typeLabel"), ""));
11996
+ if (result.userAvailable) form.appendChild(labelled(scope, t("memory.scopeLabel"), note ? "" : t("memory.scopeHint")));
9420
11997
  const actions = el("div", "memory-actions");
9421
11998
  const save = el("button", "ghost", note ? t("memory.save") : t("memory.add")); save.type = "submit"; actions.appendChild(save);
9422
11999
  // Delete belongs beside Save, not stranded under the card: they are the two
@@ -9424,21 +12001,29 @@ async function showMemoryPage(id) {
9424
12001
  if (extra) actions.appendChild(extra);
9425
12002
  form.appendChild(actions);
9426
12003
  form.onsubmit = async (event) => { event.preventDefault(); save.disabled = true;
9427
- 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;
12004
+ await act({ action: "save", scope: note ? note.scope : scope.value, ...(note ? { id: note.id, expectedHash: note.hash } : {}), note: { type: type.value, summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean) } }); save.disabled = false;
9428
12005
  };
9429
12006
  return form;
9430
12007
  };
9431
- const add = el("details", "row-card memory-note");
9432
- const addHead = el("summary", "");
9433
- addHead.appendChild(el("span", "chev"));
9434
- addHead.appendChild(el("span", "memory-note-name", t("memory.add")));
9435
- add.appendChild(addHead); add.appendChild(editor()); panel.appendChild(add);
9436
- 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);
12008
+ // What notes are, said once above the list. They come from conversations,
12009
+ // so there is no form for writing one here; existing notes open to edit.
12010
+ panel.appendChild(el("div", "section-title", t("memory.notesTitle", { count: result.entries.length })));
12011
+ // With no notes the empty state below says all of this, with an example.
12012
+ if (result.entries.length) panel.appendChild(el("p", "muted small memory-notes-intro", t("memory.notesIntro")));
12013
+ const search = el("input", "memory-search"); search.placeholder = t("memory.search"); search.setAttribute("aria-label", t("memory.search")); search.style.margin = "16px 0"; search.hidden = !result.entries.length; panel.appendChild(search);
9437
12014
  const list = el("div", ""); panel.appendChild(list);
9438
12015
  const draw = () => {
9439
12016
  list.replaceChildren(); const query = search.value.toLowerCase();
9440
- const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ")).toLowerCase().includes(query));
9441
- if (!entries.length) list.appendChild(el("p", "muted", t("memory.none")));
12017
+ const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ") + " " + (note.type || "") + " " + note.id).toLowerCase().includes(query));
12018
+ if (!entries.length) {
12019
+ if (result.entries.length) list.appendChild(el("p", "muted small", t("memory.none")));
12020
+ else {
12021
+ const empty = el("div", "row-card memory-empty");
12022
+ empty.appendChild(el("div", "memory-empty-title", t("memory.emptyTitle")));
12023
+ empty.appendChild(el("p", "muted small", t("memory.empty")));
12024
+ list.appendChild(empty);
12025
+ }
12026
+ }
9442
12027
  for (const note of entries) {
9443
12028
  const row = el("details", "row-card memory-note");
9444
12029
  /*
@@ -9455,14 +12040,71 @@ async function showMemoryPage(id) {
9455
12040
  head.appendChild(el("span", "chev"));
9456
12041
  head.appendChild(el("span", "memory-note-name", note.summary));
9457
12042
  head.appendChild(el("span", "memory-id mono", note.id));
12043
+ const kind = [note.scope === "user" ? t("memory.scopeUser") : "", note.type ? t("memory.type" + note.type.charAt(0).toUpperCase() + note.type.slice(1)) : ""].filter(Boolean).join(" · ");
12044
+ if (kind) head.appendChild(el("span", "memory-id", kind));
9458
12045
  row.appendChild(head);
12046
+ row.dataset.noteId = note.id;
12047
+ /*
12048
+ * Links written as [[id]] in a note, both ways.
12049
+ *
12050
+ * Resolved by the plugin, since only it sees both scopes; the page only
12051
+ * draws them. A link to a note that does not exist is shown struck
12052
+ * through rather than hidden: it usually names a note someone meant to
12053
+ * write.
12054
+ */
12055
+ const links = note.links || { out: [], in: [], dangling: [] };
12056
+ if (links.out.length || links.in.length || links.dangling.length) {
12057
+ const line = el("div", "memory-links small");
12058
+ const chip = (target) => {
12059
+ const button = el("button", "memory-link", target); button.type = "button";
12060
+ button.onclick = () => {
12061
+ const found = list.querySelector('[data-note-id="' + CSS.escape(target) + '"]');
12062
+ if (found) { found.open = true; found.scrollIntoView({ block: "center" }); }
12063
+ };
12064
+ return button;
12065
+ };
12066
+ if (links.out.length || links.dangling.length) {
12067
+ line.appendChild(el("span", "", t("memory.linksOut")));
12068
+ for (const target of links.out) line.appendChild(chip(target));
12069
+ for (const target of links.dangling) { const missing = el("span", "memory-link missing", target); missing.title = t("memory.missing"); line.appendChild(missing); }
12070
+ }
12071
+ if (links.in.length) {
12072
+ line.appendChild(el("span", "", t("memory.linksIn")));
12073
+ for (const target of links.in) line.appendChild(chip(target));
12074
+ }
12075
+ row.appendChild(line);
12076
+ }
9459
12077
  const remove = el("button", "ghost", t("memory.delete")); remove.type = "button";
9460
- remove.onclick = () => { if (confirm(t("memory.confirmDelete"))) void act({ action: "delete", id: note.id, expectedHash: note.hash }); };
12078
+ remove.onclick = () => {
12079
+ const linked = links.in.length ? t("memory.confirmDeleteLinked", { count: links.in.length }) : "";
12080
+ if (confirm(linked + t("memory.confirmDelete"))) void act({ action: "delete", scope: note.scope, id: note.id, expectedHash: note.hash });
12081
+ };
9461
12082
  row.appendChild(editor(note, remove));
9462
12083
  list.appendChild(row);
9463
12084
  }
9464
12085
  };
9465
12086
  search.oninput = draw; draw();
12087
+ // Backups: every version replaced or removed without anyone looking, newest first.
12088
+ const backupList = result.backups || [];
12089
+ const backups = el("details", "row-card memory-note");
12090
+ const backupsHead = el("summary", "");
12091
+ backupsHead.appendChild(el("span", "chev"));
12092
+ backupsHead.appendChild(el("span", "memory-note-name", t("memory.backups", { count: backupList.length })));
12093
+ backups.appendChild(backupsHead);
12094
+ if (!backupList.length) backups.appendChild(el("p", "muted", t("memory.backupsNone")));
12095
+ for (const backup of backupList) {
12096
+ const row = el("div", "memory-backup");
12097
+ const text = el("div", "memory-backup-text");
12098
+ text.appendChild(el("div", "", backup.summary));
12099
+ const scopeLabel = backup.scope === "user" ? t("memory.scopeUser") : backup.scope === "project" ? t("memory.scopeProject") : "";
12100
+ text.appendChild(el("div", "muted small", [new Date(backup.backedUpAt).toLocaleString(LOCALE), backup.reason ? t("memory.backupReason." + backup.reason) : "", scopeLabel, backup.id].filter(Boolean).join(" · ")));
12101
+ row.appendChild(text);
12102
+ const restore = el("button", "ghost", t("memory.restore")); restore.type = "button";
12103
+ restore.onclick = () => { if (confirm(t("memory.confirmRestore"))) void act({ action: "restore", file: backup.file }); };
12104
+ row.appendChild(restore);
12105
+ backups.appendChild(row);
12106
+ }
12107
+ if (backupList.length) panel.appendChild(backups);
9466
12108
  }
9467
12109
 
9468
12110
  /**
@@ -9474,7 +12116,7 @@ async function showMemoryPage(id) {
9474
12116
  * sessions have no reason to open. It keeps the row it came from lit, so the
9475
12117
  * sidebar still says where in the app this is.
9476
12118
  */
9477
- const SUB_PANELS = { usage: "nav-settings" };
12119
+ const SUB_PANELS = { usage: "nav-settings", audit: "nav-settings", permissions: "nav-settings" };
9478
12120
 
9479
12121
  /** Which panel the panel view is currently showing, sub-panels included. */
9480
12122
  let shownPanel = null;
@@ -9563,6 +12205,8 @@ function repaintPanel() {
9563
12205
  else if (shownPanel === "nav-routines") void showRoutines();
9564
12206
  else if (shownPanel === "nav-settings") void showSettings();
9565
12207
  else if (shownPanel === "usage") void showUsage();
12208
+ else if (shownPanel === "audit") void showAudit();
12209
+ else if (shownPanel === "permissions") void showPermissions();
9566
12210
  else if (shownPanel?.startsWith("nav-plugin-")) void showMemoryPage(shownPanel.slice("nav-plugin-".length));
9567
12211
  }
9568
12212
 
@@ -10136,7 +12780,7 @@ export const PAGE = `<!doctype html>
10136
12780
  <html lang="en">
10137
12781
  <head>
10138
12782
  <meta charset="utf-8">
10139
- <meta name="viewport" content="width=device-width,initial-scale=1">
12783
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
10140
12784
  <meta name="referrer" content="no-referrer">
10141
12785
  <title>OHarness</title>
10142
12786
  <link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON)}">
@@ -10155,6 +12799,7 @@ ${ANALYTICS_STUB}
10155
12799
  </svg>
10156
12800
  </span>
10157
12801
  <b class="wordmark">OHarness</b>
12802
+ <button id="nav-close" type="button" aria-label="Close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
10158
12803
  </header>
10159
12804
  <button id="project" class="project" type="button">
10160
12805
  <span class="avatar" id="project-avatar"></span>
@@ -10191,6 +12836,7 @@ ${ANALYTICS_STUB}
10191
12836
  model-provider setup — a place nobody goes to find their account. -->
10192
12837
  <div id="account"></div>
10193
12838
  </aside>
12839
+ <div id="scrim"></div>
10194
12840
 
10195
12841
  <main>
10196
12842
  <div id="top">
@@ -10260,12 +12906,16 @@ ${ANALYTICS_STUB}
10260
12906
  <button type="button" class="picker-btn"><span class="picker-value">…</span><svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5"/></svg></button>
10261
12907
  <div class="menu" hidden></div>
10262
12908
  </div>
12909
+ <div class="picker" id="effort-picker" hidden>
12910
+ <button type="button" class="picker-btn" aria-haspopup="true"><svg class="ico-sm" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.4 11.5a5.6 5.6 0 1 1 11.2 0"/><path d="M8 11.5 10.7 7.6"/></svg><span class="picker-value">…</span><svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5"/></svg></button>
12911
+ <div class="menu" hidden></div>
12912
+ </div>
10263
12913
  <div class="picker" id="mode-picker">
10264
12914
  <button type="button" class="picker-btn"><span class="picker-value">…</span><svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5"/></svg></button>
10265
12915
  <div class="menu" hidden></div>
10266
12916
  </div>
10267
12917
  <span class="composer-spacer"></span>
10268
- <button id="stop" class="ghost" type="button" hidden data-t="composer.stop"></button>
12918
+ <button id="stop" class="primary" type="button" hidden data-t-title="composer.stop"><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg></button>
10269
12919
  <button id="send" class="primary" type="submit" data-t-title="composer.send"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3.4M3.6 7.8 8 3.2l4.4 4.6"/></svg></button>
10270
12920
  </div>
10271
12921
  </div>
@@ -10289,6 +12939,7 @@ ${ANALYTICS_STUB}
10289
12939
  <div id="review-head">
10290
12940
  <span id="review-title" data-t="review.changes"></span>
10291
12941
  <span id="review-sum"></span>
12942
+ <button id="review-close" type="button" aria-label="Close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
10292
12943
  </div>
10293
12944
  <div id="files"></div>
10294
12945
  <div id="diff"></div>
@@ -10314,7 +12965,7 @@ ${ANALYTICS_STUB}
10314
12965
  </dialog>
10315
12966
 
10316
12967
  <dialog id="palette">
10317
- <div class="pp-head"><input id="cmdk-search" type="text" data-t-placeholder="palette.placeholder" autocomplete="off" spellcheck="false"></div>
12968
+ <div class="pp-head cmdk-head"><svg class="ico-sm" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.33" cy="7.33" r="5.33"/><path d="m14 14-2.87-2.87"/></svg><input id="cmdk-search" type="text" data-t-placeholder="palette.placeholder" autocomplete="off" spellcheck="false"></div>
10318
12969
  <div id="cmdk-list"></div>
10319
12970
  <div class="cmdk-foot">
10320
12971
  <span><kbd>↑↓</kbd><span data-t="palette.moveHint"></span></span>
@@ -10405,8 +13056,11 @@ ${ANALYTICS_STUB}
10405
13056
 
10406
13057
  <dialog id="ask">
10407
13058
  <div class="ask-head">
10408
- <div class="ask-tags"><span id="ask-tag" class="ask-tag"></span><span id="ask-tool" class="ask-tool"></span></div>
10409
- <div id="ask-target" class="ask-target"></div>
13059
+ <span id="ask-tag" class="ask-tag"></span>
13060
+ <div class="ask-title">
13061
+ <img id="ask-logo" class="ask-logo" alt="" referrerpolicy="no-referrer" hidden>
13062
+ <div id="ask-target" class="ask-target"></div>
13063
+ </div>
10410
13064
  <div id="ask-meta" class="ask-meta"></div>
10411
13065
  </div>
10412
13066
  <div id="ask-body" class="ask-body"></div>
@@ -10420,7 +13074,7 @@ ${ANALYTICS_STUB}
10420
13074
  <button data-answer="session" data-scope="session"><span data-t="ask.session"></span></button>
10421
13075
  <button data-answer="project" data-scope="project"><span data-t="ask.project"></span></button>
10422
13076
  </div>
10423
- <div id="ask-scope" class="ask-scope"></div>
13077
+ <div id="ask-scope" class="ask-scope" hidden></div>
10424
13078
  </dialog>
10425
13079
 
10426
13080
  <script>