@oharness/server 0.5.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}
@@ -239,6 +200,28 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
239
200
  }
240
201
  .menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
241
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)}
242
225
  .menu-billing-actions{display:flex;gap:7px;margin-top:10px}
243
226
  .menu-billing-action{
244
227
  flex:1;min-height:30px;padding:6px 10px;border:0;border-radius:7px;
@@ -550,7 +533,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
550
533
  font-size:11px;opacity:.6;flex:0 0 auto;
551
534
  }
552
535
  .tab .where::after{content:"/";padding-left:5px;opacity:.5}
553
- .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}
554
537
  .tab .x{
555
538
  width:17px;height:17px;border:0;border-radius:5px;background:transparent;color:inherit;
556
539
  font:inherit;font-size:14px;line-height:1;cursor:pointer;opacity:0;flex:0 0 auto;
@@ -672,7 +655,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
672
655
  border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);
673
656
  transition:transform .15s;
674
657
  }
675
- .switch[aria-checked="true"]{background:var(--accent)}
658
+ .switch[aria-checked="true"]{background:var(--brand)}
676
659
  .switch[aria-checked="true"]::after{transform:translateX(14px)}
677
660
  /* Disabled means "not yet", not "never": it stays legible, and the row says
678
661
  why. Hiding it instead left people looking for a control that exists. */
@@ -809,6 +792,60 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
809
792
  * they are answering. */
810
793
  .connection-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:2px}
811
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}
812
849
  .who{
813
850
  font-size:11.5px;color:var(--dim);margin-bottom:6px;font-weight:500;letter-spacing:.01em;
814
851
  }
@@ -825,6 +862,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
825
862
  }
826
863
  .user{margin-bottom:18px}
827
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}
828
866
  .panel h2{font-size:18px;font-weight:600;letter-spacing:-.01em;margin:0 0 14px}
829
867
  /* Only the gap below. The horizontal margin belongs to whatever is placing the
830
868
  * card: setting all four here quietly reset the auto margins that keep a card
@@ -845,6 +883,19 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
845
883
  .memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
846
884
  .memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
847
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}
848
899
  /* Built like a settings row: one flex line, the marker drawn rather than left
849
900
  * to the browser, so the summary text and everything below it share one left
850
901
  * edge. */
@@ -864,6 +915,18 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
864
915
  /* The form's own background is the page's, which on a card reads as a second
865
916
  * card inside the first one — the same doubling the padding was making. */
866
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}
867
930
  /*
868
931
  * A settings row: name, value, and everything else behind the triangle.
869
932
  *
@@ -929,10 +992,19 @@ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
929
992
  /* Lists that could be forty lines long scroll where they are rather than
930
993
  growing the page under them. */
931
994
  .scroll-box{
932
- max-height:200px;overflow-y:auto;border:1px solid var(--line);
995
+ max-height:200px;overflow-y:auto;background:var(--field);
933
996
  border-radius:6px;padding:7px 9px;
934
997
  }
935
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
+ }
936
1008
  /* A row of things to do with what is above them. Links styled as the ghost
937
1009
  buttons beside them, because a download is a link and pretending otherwise
938
1010
  costs the browser's own save machinery. */
@@ -953,6 +1025,97 @@ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
953
1025
  * and five tints of one colour cannot do it.
954
1026
  */
955
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
+ }
956
1119
  .seg-item{
957
1120
  border:0;background:transparent;color:var(--dim);font:inherit;font-size:12.5px;
958
1121
  padding:5px 11px;border-radius:7px;cursor:pointer;
@@ -1292,17 +1455,17 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1292
1455
  acts, and a button inside a button is not a thing. The forget stays hidden
1293
1456
  until the row is under the pointer — it is the rarer of the two by a wide
1294
1457
  margin, and a column of them reads as a list of things to delete. */
1295
- /* The project header, plus the one control that acts on the whole project.
1296
- Same shape as a session row's: hidden until the row is hovered, and held
1297
- 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. */
1298
1461
  .fold-row{display:flex;align-items:center}
1299
1462
  .fold-row .fold{flex:1;min-width:0}
1300
1463
  .fold-row .more{
1301
- 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;
1302
1465
  color:var(--dim);cursor:pointer;padding:0;place-items:center;flex:0 0 auto;margin-right:6px;
1303
1466
  }
1304
- .fold-row:hover .more,.fold-row .fold.menu-open + .more{display:grid}
1305
- .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)}
1306
1469
  .fold-row .more .ico-sm{width:14px;height:14px;opacity:1}
1307
1470
  .proj-row{display:flex;align-items:center;border-radius:8px}
1308
1471
  .proj-row:hover{background:var(--hover)}
@@ -1321,15 +1484,23 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1321
1484
  * here, the projects themselves, and the few actions worth a shortcut. */
1322
1485
  #palette{width:min(560px,92vw);max-height:min(60vh,520px)}
1323
1486
  #palette[open]{display:flex;flex-direction:column}
1324
- #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}
1325
1495
  .cmdk-group{
1326
- 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;
1327
1497
  color:var(--dim);
1328
1498
  }
1499
+ .cmdk-group:first-child{padding-top:6px}
1329
1500
  .cmdk-item{
1330
- 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;
1331
1502
  background:transparent;color:var(--fg);font:inherit;font-size:13px;cursor:pointer;
1332
- padding:8px;text-align:left;
1503
+ padding:8px 14px;text-align:left;
1333
1504
  }
1334
1505
  /* Hover and the keyboard cursor are the same highlight: a list driven by both
1335
1506
  at once needs one answer to "which row does Enter take". */
@@ -1340,9 +1511,9 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1340
1511
  font-size:11px;color:var(--dim);flex:0 0 auto;max-width:45%;
1341
1512
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1342
1513
  }
1343
- .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)}
1344
1515
  .cmdk-foot{
1345
- 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);
1346
1517
  box-shadow:0 -1px 0 var(--line);
1347
1518
  }
1348
1519
  .cmdk-foot kbd{
@@ -1506,11 +1677,25 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1506
1677
  .act .ico-sm{width:13px;height:13px;opacity:.9}
1507
1678
  .md{word-break:break-word}
1508
1679
  .md-p{margin:0 0 10px;white-space:pre-wrap}
1509
- .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}
1510
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}
1511
1689
  .md-quote{margin:0 0 10px;padding-left:12px;border-left:3px solid var(--line);color:var(--dim)}
1512
1690
  .md-code{background:var(--field);border-radius:5px;padding:1.5px 5px;font-size:13px}
1513
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)}
1514
1699
  .code-wrap{border-radius:10px;overflow:hidden;margin:0 0 12px;background:var(--panel)}
1515
1700
  .code-bar{
1516
1701
  display:flex;justify-content:space-between;align-items:center;padding:4px 10px;
@@ -1528,7 +1713,7 @@ details.tool>summary{
1528
1713
  font-size:13px;list-style:none;
1529
1714
  }
1530
1715
  details.tool>summary::-webkit-details-marker{display:none}
1531
- .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}
1532
1717
  .tool.done .dot{background:var(--ok)}
1533
1718
  .tool.failed .dot{background:var(--err)}
1534
1719
  .tool .name{font-family:ui-monospace,monospace}
@@ -1536,6 +1721,9 @@ details.tool>summary::-webkit-details-marker{display:none}
1536
1721
  .tool-body{padding:2px 12px 10px}
1537
1722
  .tool-body pre{margin:0;overflow-x:auto;font-size:12px;max-height:340px}
1538
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}
1539
1727
  pre.diff{margin:0;font-size:12px;line-height:1.5;overflow-x:auto}
1540
1728
  .d-add{background:var(--add-bg);color:var(--add)}
1541
1729
  .d-del{background:var(--del-bg);color:var(--del)}
@@ -1556,13 +1744,29 @@ details.work[open]>summary::before{transform:rotate(90deg)}
1556
1744
  .working-time{font-size:12px;opacity:.7}
1557
1745
  .spinner{
1558
1746
  width:12px;height:12px;flex:0 0 auto;border-radius:50%;
1559
- border:1.5px solid var(--line);border-top-color:var(--dim);
1747
+ border:1.5px solid var(--line);border-top-color:var(--brand);
1560
1748
  animation:spin .7s linear infinite;
1561
1749
  }
1562
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}}
1563
1763
  @media (prefers-reduced-motion:reduce){
1564
1764
  /* A spinner is decoration; the elapsed seconds carry the same information. */
1565
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
+ }
1566
1770
  }
1567
1771
 
1568
1772
  /* reasoning */
@@ -1573,6 +1777,13 @@ details.thinking>summary{
1573
1777
  }
1574
1778
  details.thinking>summary::-webkit-details-marker{display:none}
1575
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)}
1576
1787
  .thinking-body{
1577
1788
  white-space:pre-wrap;color:var(--dim);font-size:13.5px;line-height:1.6;
1578
1789
  margin-top:8px;padding-left:12px;border-left:2px solid var(--line);
@@ -1612,8 +1823,8 @@ textarea::placeholder{color:var(--dim)}
1612
1823
  the field only has to agree on where each glyph lands, not on what is
1613
1824
  drawn there. The selection is translucent so the words under it stay
1614
1825
  readable; an opaque highlight would sit on top of the mirror. */
1615
- .input-wrap textarea{color:transparent;caret-color:var(--fg)}
1616
- .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)}
1617
1828
  /* Every metric that decides where a glyph lands has to match the field: font,
1618
1829
  line-height, wrapping, and the padding, which is zero on this textarea.
1619
1830
  Six pixels wider on each side with the text pushed back in by six: the
@@ -1658,7 +1869,7 @@ textarea::placeholder{color:var(--dim)}
1658
1869
  where a glyph lands has to match: font, line-height, wrapping, and the
1659
1870
  padding, which is zero on the composer's textarea and must be zero here. */
1660
1871
  textarea.boxed{
1661
- border:1px solid var(--line);background:var(--field);border-radius:6px;
1872
+ border:0;background:var(--field);border-radius:6px;
1662
1873
  padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
1663
1874
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
1664
1875
  }
@@ -1675,6 +1886,41 @@ textarea.boxed:focus{outline:none;background:var(--field-focus)}
1675
1886
  settings on one bar, none of them shouting over the others. */
1676
1887
  #workspace-picker .picker-btn{gap:6px}
1677
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}
1678
1924
  #attachments{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
1679
1925
  #attachments:empty{display:none}
1680
1926
  .att{
@@ -1790,36 +2036,48 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1790
2036
  * happen to which file. The parameters used to be dumped as JSON, which put
1791
2037
  * escaped newlines and the tool's own field names in front of the one thing
1792
2038
  * worth reading — so the body renders the payload instead, and the plumbing
1793
- * moved behind a toggle. */
1794
- #ask{width:min(620px,94vw);max-width:min(620px,94vw)}
1795
- .ask-head{padding:16px 18px 12px;border-bottom:1px solid var(--line)}
1796
- .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}
1797
2046
  .ask-tag{
1798
- 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;
1799
2048
  background:var(--field);color:var(--dim);
1800
2049
  }
1801
- .ask-tool{font-size:11.5px;color:var(--dim)}
1802
- .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{
1803
2054
  font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
1804
- 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;
1805
2056
  }
1806
- .ask-meta{font-size:12px;color:var(--dim);margin-top:3px;word-break:break-all}
1807
- .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}
1808
2060
  .ask-body.clamped{max-height:none;overflow:hidden}
1809
2061
  .ask-line{
1810
2062
  display:flex;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
1811
2063
  font-size:12.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;
1812
2064
  }
1813
2065
  .ask-line .n{
1814
- 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);
1815
2067
  opacity:.65;user-select:none;
1816
2068
  }
2069
+ .ask-body.diff .ask-line .n{flex-basis:28px;text-align:center;padding-right:4px}
1817
2070
  .ask-line .s{flex:1;padding-right:14px}
1818
2071
  .ask-line.add{background:var(--add-bg)}
1819
2072
  .ask-line.add .s{color:var(--add)}
1820
2073
  .ask-line.del{background:var(--del-bg)}
1821
2074
  .ask-line.del .s{color:var(--del)}
1822
- .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}
1823
2081
  .ask-more button{
1824
2082
  border:0;border-radius:7px;background:transparent;color:var(--dim);
1825
2083
  font:inherit;font-size:12px;padding:4px 8px;cursor:pointer;
@@ -1828,7 +2086,7 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1828
2086
  /* The three allow options are one decision at three scopes, not three
1829
2087
  * decisions, so they sit on one row as peers. Splitting them across two rows
1830
2088
  * under a "remember the answer" heading asked the same question twice. */
1831
- .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}
1832
2090
  /* Holds the way out apart from the answers without a spacer element: a growing
1833
2091
  * spacer claims width of its own, which forced the row to wrap one button early
1834
2092
  * on a narrow window. The quiet button leads its row, so the gap goes after it. */
@@ -1855,12 +2113,22 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1855
2113
  .dlg-actions button.quiet{background:transparent;color:var(--dim)}
1856
2114
  .dlg-actions button.quiet:hover{background:var(--hover);color:var(--fg)}
1857
2115
  .dlg-actions kbd{
1858
- font:inherit;font-size:11px;opacity:.6;border:1px solid currentColor;border-radius:4px;
1859
- padding:0 4px;line-height:1.5;
1860
- }
1861
- .ask-scope{
1862
- padding:8px 18px 16px;font-size:11.5px;line-height:1.6;color:var(--dim);min-height:34px;
1863
- }
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}
1864
2132
 
1865
2133
  /*
1866
2134
  * Phone-only chrome, declared before the breakpoint that shows it: a base
@@ -1946,6 +2214,12 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1946
2214
  left:0;right:0;width:auto;min-width:0;max-width:none;bottom:calc(100% + 8px);
1947
2215
  }
1948
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}
1949
2223
  #jump{right:14px;bottom:160px}
1950
2224
 
1951
2225
  /* Dialogs, with the keyboard up: vh is the height without it. */
@@ -1956,6 +2230,10 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1956
2230
  .mk-search{padding-right:44px}
1957
2231
  .ask-body{max-height:min(260px,40dvh)}
1958
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}
1959
2237
  }
1960
2238
 
1961
2239
  /*
@@ -2058,6 +2336,9 @@ const ICON_PATHS = {
2058
2336
  // both menus open upward, and both mark themselves with a caret that reads as
2059
2337
  // "there is a list here" rather than one that promises a direction.
2060
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"],
2061
2342
  // The three themes, drawn the same way the site draws them so the two
2062
2343
  // controls are recognisably the same control.
2063
2344
  themeSystem: ["M6 13.4h4"],
@@ -2150,6 +2431,12 @@ const ICON_PATHS = {
2150
2431
  // Bypass leaves the gate open: a padlock with its shackle swung clear.
2151
2432
  modeBypass: ["M5.4 7.4V5.2a2.6 2.6 0 0 1 5-1"],
2152
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"],
2153
2440
  };
2154
2441
 
2155
2442
  function svgIcon(name, extra) {
@@ -2270,6 +2557,25 @@ async function getJson(path) {
2270
2557
  }
2271
2558
 
2272
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
+
2273
2579
  function panelError(message) {
2274
2580
  const panel = el("div", "panel");
2275
2581
  const box = el("div", "row-card");
@@ -2732,6 +3038,139 @@ const modelPicker = makePicker(
2732
3038
  );
2733
3039
  const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
2734
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
+
2735
3174
  /**
2736
3175
  * Which tree the session about to start will run in.
2737
3176
  *
@@ -3012,19 +3451,149 @@ function bumpWorkLabel(group) {
3012
3451
  count === 1 ? "1 step" : count + " steps";
3013
3452
  }
3014
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
+
3015
3572
  function toolCard(id, title, name, input) {
3016
3573
  const card = el("details", "tool");
3017
3574
  const summary = el("summary");
3018
3575
  summary.appendChild(el("span", "dot"));
3019
- summary.appendChild(el("span", "name", title || name));
3576
+ summary.appendChild(el("span", "name", toolTitle(name, input, title)));
3020
3577
  summary.appendChild(el("span", "ms", ""));
3021
3578
  card.appendChild(summary);
3022
3579
 
3023
3580
  const body = el("div", "tool-body");
3024
- body.appendChild(el("div", "label", "input"));
3025
- const pre = el("pre", "mono");
3026
- pre.textContent = JSON.stringify(input, null, 2);
3027
- 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
+ }
3028
3597
  card.appendChild(body);
3029
3598
 
3030
3599
  const group = workGroup();
@@ -3054,7 +3623,18 @@ function finishTool(id, result, ms, isError) {
3054
3623
  } else {
3055
3624
  const pre = el("pre", "mono");
3056
3625
  pre.textContent = text.length > 4000 ? text.slice(0, 4000) + "\n… truncated" : text;
3057
- 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
+ }
3058
3638
  }
3059
3639
  if (isError) {
3060
3640
  // A failed step is the one thing worth surfacing without a click.
@@ -3207,6 +3787,7 @@ function applyState(s) {
3207
3787
  if (!s) return;
3208
3788
  if (s.model) modelPicker.set(modelPicker.options || [], s.model);
3209
3789
  if (s.mode) modePicker.set(MODES, s.mode);
3790
+ effortPicker.set(s);
3210
3791
  if (s.cwd) {
3211
3792
  // Kept so the approval dialog can show a path the way the user thinks of
3212
3793
  // it — src/web/page.ts, not the whole absolute path they already know.
@@ -3279,7 +3860,9 @@ function stopWorking() {
3279
3860
  function setBusy(busy) {
3280
3861
  if (P) P.busy = busy;
3281
3862
  paintTabs();
3863
+ // While a turn runs, the send arrow becomes a stop square in the same spot.
3282
3864
  sendBtn.disabled = busy;
3865
+ sendBtn.hidden = busy;
3283
3866
  stopBtn.hidden = !busy;
3284
3867
  if (!busy) stopWorking();
3285
3868
  }
@@ -3334,9 +3917,21 @@ function projectHead(cwd, name, count, open) {
3334
3917
  if ((projects.find((project) => project.path === cwd) || {}).pinned) {
3335
3918
  head.appendChild(svgIcon("pin", "pin-mark"));
3336
3919
  }
3337
- if (count) head.appendChild(el("span", "fold-count", String(count)));
3338
3920
  head.addEventListener("click", () => toggleProject(cwd, open));
3339
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
+
3340
3935
  /*
3341
3936
  * Deleting a project is deleting its transcripts, and it lives here rather
3342
3937
  * than beside "remove from list" in the picker because this header is the
@@ -3403,6 +3998,7 @@ function projectHead(cwd, name, count, open) {
3403
3998
  // The header is a button, so the menu control cannot live inside it.
3404
3999
  const wrap = el("div", "fold-row");
3405
4000
  wrap.appendChild(head);
4001
+ wrap.appendChild(add);
3406
4002
  wrap.appendChild(more);
3407
4003
 
3408
4004
  draggable(wrap, {
@@ -4097,6 +4693,7 @@ const connectionCards = new Map();
4097
4693
 
4098
4694
  function connectionCard(card) {
4099
4695
  connectionCards.set(card.id, card);
4696
+ learnCloudApp(card.app, card.label, card.logo);
4100
4697
  const target = pane(card.session);
4101
4698
  let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
4102
4699
  if (!box) {
@@ -4228,6 +4825,11 @@ function renderHistory(entries, into) {
4228
4825
  for (const card of connectionCards.values()) {
4229
4826
  if (pane(card.session) === P) connectionCard(card);
4230
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
+ }
4231
4833
  atBottom = true;
4232
4834
  log.scrollTop = log.scrollHeight;
4233
4835
  }
@@ -4396,6 +4998,20 @@ events.onmessage = (message) => {
4396
4998
  }
4397
4999
 
4398
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;
4399
5015
  case "permission_result":
4400
5016
  if (!event.allowed) addNotice("denied: " + (event.reason || ""), "err");
4401
5017
  else if (event.remembered) addNotice("remembered: " + event.remembered);
@@ -4407,6 +5023,12 @@ events.onmessage = (message) => {
4407
5023
  break;
4408
5024
 
4409
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;
4410
5032
  case "notice": addNotice(event.message); break;
4411
5033
  case "error":
4412
5034
  P.streaming = null;
@@ -4503,6 +5125,12 @@ const askExpand = document.getElementById("ask-expand");
4503
5125
  const askRaw = document.getElementById("ask-raw");
4504
5126
  const askScope = document.getElementById("ask-scope");
4505
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 = [];
4506
5134
 
4507
5135
  /**
4508
5136
  * The rule a "remember" answer would write.
@@ -4543,65 +5171,123 @@ function askSize(text) {
4543
5171
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
4544
5172
  }
4545
5173
 
4546
- const askLines = (text, kind) =>
4547
- 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
+ };
4548
5180
 
4549
5181
  /**
4550
5182
  * The request, rearranged for a reader.
4551
5183
  *
4552
5184
  * The payload of a write is the file content, not the JSON object carrying it;
4553
5185
  * the payload of an edit is the substitution, which only means anything as a
4554
- * diff. Tools with no special handling still get their parameters, just as
4555
- * 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.
4556
5189
  */
4557
5190
  function askView(request) {
4558
5191
  const name = (request.toolName || "").toLowerCase();
4559
5192
  const input = request.input && typeof request.input === "object" ? request.input : {};
4560
- 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
+ };
4561
5204
 
4562
5205
  if (name === "write" && typeof input.content === "string") {
4563
- const count = input.content === "" ? 0 : input.content.split("\n").length;
4564
5206
  view.tag = t("ask.tagWrite");
4565
- view.target = askPath(input.path);
4566
- view.meta = [
4567
- 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 }),
4568
5211
  askSize(input.content),
4569
- ].join(" · ");
4570
- view.lines = askLines(input.content, "");
5212
+ );
4571
5213
  return view;
4572
5214
  }
4573
5215
 
4574
5216
  if (name === "edit" && typeof input.newString === "string") {
4575
- const removed = askLines(String(input.oldString ?? ""), "del");
4576
- 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") : [];
4577
5219
  view.tag = input.replaceAll ? t("ask.tagEditAll") : t("ask.tagEdit");
4578
- view.target = askPath(input.path);
4579
- 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 }));
4580
5222
  view.lines = removed.concat(added);
4581
5223
  return view;
4582
5224
  }
4583
5225
 
4584
5226
  if (name === "bash" && typeof input.command === "string") {
4585
5227
  view.tag = t("ask.tagBash");
4586
- // The command is the thing being judged, so it is the headline and there
4587
- // is no body to fold under it.
4588
- view.target = input.command;
4589
- 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;
4590
5242
  return view;
4591
5243
  }
4592
5244
 
4593
5245
  if (typeof input.url === "string") {
4594
5246
  view.tag = t("ask.tagFetch");
4595
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;
4596
5251
  return view;
4597
5252
  }
4598
5253
 
4599
- if (typeof input.path === "string") view.target = askPath(input.path);
4600
- else if (typeof input.pattern === "string") view.target = input.pattern;
4601
- 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;
4602
5257
  return view;
4603
5258
  }
4604
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
+
4605
5291
  /** A path shown relative to the working directory when it sits inside it. */
4606
5292
  function askPath(value) {
4607
5293
  if (typeof value !== "string") return "";
@@ -4611,52 +5297,81 @@ function askPath(value) {
4611
5297
 
4612
5298
  function renderAskBody() {
4613
5299
  if (!askState) return;
5300
+ const { view, request } = askState;
4614
5301
  askBody.replaceChildren();
4615
- const all = askState.view.lines;
4616
- const shown = askState.raw
4617
- ? askLines(JSON.stringify(askState.request.input, null, 2), "")
4618
- : askState.expanded ? all : all.slice(0, ASK_FOLD);
4619
- for (const [index, line] of shown.entries()) {
4620
- const row = el("div", "ask-line" + (line.kind ? " " + line.kind : ""));
4621
- // A diff has no line numbers to give: the replacement has not happened, so
4622
- // counting the rows would name positions that do not exist in the file.
4623
- const gutter = line.kind === "del" ? "−" : line.kind === "add" ? "+" : String(index + 1);
4624
- row.append(el("span", "n", gutter), el("span", "s", line.s || " "));
4625
- askBody.append(row);
4626
- }
4627
- askBody.classList.toggle("clamped", !askState.expanded && !askState.raw);
4628
- askBody.hidden = shown.length === 0;
4629
-
4630
- const foldable = !askState.raw && all.length > ASK_FOLD;
4631
- askExpand.hidden = !foldable;
4632
- 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;
4633
5338
  askRaw.textContent = askState.raw ? t("ask.rawHide") : t("ask.raw");
4634
5339
  askMore.hidden = askExpand.hidden && askRaw.hidden;
4635
5340
  }
4636
5341
 
4637
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);
4638
5345
  pendingId = event.id;
4639
5346
  const request = event.request;
4640
5347
  const view = askView(request);
4641
5348
  askState = { request, view, expanded: false, raw: false };
4642
5349
 
4643
- document.getElementById("ask-tag").textContent = view.tag;
4644
- document.getElementById("ask-tool").textContent = request.toolName || "";
4645
- document.getElementById("ask-target").textContent = view.target || "";
4646
- const meta = document.getElementById("ask-meta");
4647
- // The reason explains which rule made the harness ask; it belongs with the
4648
- // other metadata, not above the thing being approved.
4649
- meta.textContent = [view.meta, request.reason].filter(Boolean).join(" · ");
4650
- meta.hidden = meta.textContent === "";
5350
+ paintAskHead(view, request);
4651
5351
  askScope.textContent = "";
5352
+ askScope.hidden = true;
4652
5353
  renderAskBody();
4653
5354
  // The palette is a convenience; this is a turn waiting on an answer. Two
4654
5355
  // stacked modals would leave the deeper one unreachable behind the newer.
4655
5356
  if (paletteDialog.open) paletteDialog.close();
4656
- ask.showModal();
5357
+ if (!ask.open) ask.showModal();
4657
5358
  ask.querySelector('[data-answer="once"]').focus();
4658
5359
  }
4659
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
+
4660
5375
  askExpand.addEventListener("click", () => {
4661
5376
  askState.expanded = !askState.expanded;
4662
5377
  renderAskBody();
@@ -4671,6 +5386,9 @@ ask.addEventListener("close", () => {
4671
5386
  // Escape, the backdrop, anything other than a button: a dismissal is a
4672
5387
  // refusal. Approval has to be an explicit act.
4673
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);
4674
5392
  });
4675
5393
 
4676
5394
  // Enter approves once, which is the answer the dialog is focused on anyway.
@@ -4685,15 +5403,8 @@ ask.addEventListener("keydown", (event) => {
4685
5403
  for (const button of ask.querySelectorAll("[data-answer]")) {
4686
5404
  button.addEventListener("click", () => {
4687
5405
  const choice = button.dataset.answer;
4688
- const id = pendingId;
4689
- pendingId = null;
5406
+ answer(choice !== "no", choice === "session" || choice === "project" ? choice : null);
4690
5407
  ask.close();
4691
- if (!id) return;
4692
- void api("/permission", {
4693
- id,
4694
- allow: choice !== "no",
4695
- remember: choice === "session" || choice === "project" ? choice : undefined,
4696
- });
4697
5408
  });
4698
5409
 
4699
5410
  // What a scope actually costs, spelled out before it is chosen rather than
@@ -4705,11 +5416,16 @@ for (const button of ask.querySelectorAll("[data-answer]")) {
4705
5416
  askScope.textContent = t(scope === "session" ? "ask.scopeSession" : "ask.scopeProject", {
4706
5417
  key: askRuleKey(askState.request),
4707
5418
  });
5419
+ askScope.hidden = false;
5420
+ };
5421
+ const hide = () => {
5422
+ askScope.textContent = "";
5423
+ askScope.hidden = true;
4708
5424
  };
4709
5425
  button.addEventListener("mouseenter", describe);
4710
5426
  button.addEventListener("focus", describe);
4711
- button.addEventListener("mouseleave", () => { askScope.textContent = ""; });
4712
- button.addEventListener("blur", () => { askScope.textContent = ""; });
5427
+ button.addEventListener("mouseleave", hide);
5428
+ button.addEventListener("blur", hide);
4713
5429
  }
4714
5430
 
4715
5431
  function answer(allow, remember) {
@@ -4719,7 +5435,390 @@ function answer(allow, remember) {
4719
5435
  // Which way the answer went and how far it was remembered — never what was
4720
5436
  // being asked about, which is a command line or a path.
4721
5437
  track("permission_answered", { allow: allow, remember: remember || "once" });
4722
- 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;
4723
5822
  }
4724
5823
 
4725
5824
 
@@ -6097,6 +7196,17 @@ document.getElementById("composer").addEventListener("submit", (e) => {
6097
7196
  closeAttachMenu();
6098
7197
  return;
6099
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
+ }
6100
7210
  if (!text && !staged.length && !refs.length) return;
6101
7211
  const ids = staged.map((file) => file.id);
6102
7212
  const shots = staged
@@ -7878,7 +8988,7 @@ async function loadAccount() {
7878
8988
  const name = body.name || body.email || t("account.signedIn");
7879
8989
  row.appendChild(avatarFor(name, body.avatar));
7880
8990
  row.appendChild(el("span", "who-name", name));
7881
- row.appendChild(svgIcon("caret", "acct-caret"));
8991
+ row.appendChild(svgIcon("chevronsVertical", "acct-caret"));
7882
8992
 
7883
8993
  // The balance, once it can be read. Running out mid-turn is the failure this
7884
8994
  // prevents: a number that appears only in a paywall is a number nobody sees
@@ -7887,12 +8997,6 @@ async function loadAccount() {
7887
8997
  // worth reading on the way to the dashboard rather than at a glance. Asked
7888
8998
  // for separately so a gateway having a bad day cannot keep the row from
7889
8999
  // saying who is signed in, which is the thing it is actually for.
7890
- let balance = null;
7891
- // The plan, from the same answer the balance comes out of. It was already in
7892
- // the response and thrown away, and it is the other half of the sentence:
7893
- // "0 credits" reads as a problem to someone on a plan and as a fact of life
7894
- // to someone who never subscribed, and the two want different next steps.
7895
- let plan = null;
7896
9000
  // Whether there is a shop at all. A self-hosted gateway sells nothing, and
7897
9001
  // an entry that opens a paywall with no plans in it is worse than no entry.
7898
9002
  let sellsCredits = false;
@@ -7903,6 +9007,19 @@ async function loadAccount() {
7903
9007
  // A team the account was removed from comes back refused, and the line
7904
9008
  // says so rather than showing a number it could not read.
7905
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;
7906
9023
  knownTeams = Array.isArray(body.teams) ? body.teams : [];
7907
9024
  const teamNameOf = (id) => {
7908
9025
  const found = knownTeams.find((team) => team.id === id);
@@ -7911,19 +9028,40 @@ async function loadAccount() {
7911
9028
  void getJson("/billing").then((state) => {
7912
9029
  if (!state || !state.signedIn || state.unavailable) return;
7913
9030
  sellsCredits = true;
7914
- if (state.balanceMicro !== undefined) {
7915
- balance = t("paywall.balance", { credits: creditsOf(state.balanceMicro) });
7916
- }
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.
7917
9034
  const sub = state.subscription;
7918
- plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
7919
9035
  subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
7920
- if (state.team) {
7921
- teamLine = state.team.refused
7922
- ? t("account.teamRefused", { team: teamNameOf(state.team.id) })
7923
- : t("account.team", {
7924
- team: teamNameOf(state.team.id),
7925
- credits: creditsOf(state.team.balanceMicro || 0),
7926
- });
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;
7927
9065
  }
7928
9066
  });
7929
9067
 
@@ -7946,14 +9084,52 @@ async function loadAccount() {
7946
9084
  // dim lines the size of a footnote, above the things to click — which is
7947
9085
  // backwards: the balance is why somebody opened this menu, and the plan
7948
9086
  // is what decides which of the entries below is the one they want.
7949
- if (balance || plan || teamLine) {
7950
- const head = el("div", "menu-head");
7951
- if (balance) head.appendChild(el("div", "menu-head-main", balance));
7952
- if (plan) head.appendChild(el("div", "menu-head-sub", plan));
7953
- // Under the person's own line: it is the second balance in play, and
7954
- // the one the next call actually draws on while a team is chosen.
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.
7955
9115
  if (teamLine) head.appendChild(el("div", "menu-head-sub", teamLine));
7956
- if (sellsCredits) {
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) {
7957
9133
  const actions = el("div", "menu-billing-actions");
7958
9134
  if (!subscribed) {
7959
9135
  const subscribe = el("button", "menu-billing-action primary", t("paywall.subscribe"));
@@ -8018,6 +9194,28 @@ async function loadAccount() {
8018
9194
  }),
8019
9195
  );
8020
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
+ }
8021
9219
  items.push(
8022
9220
  menuItem(t("account.download"), {
8023
9221
  run: () => {
@@ -8699,6 +9897,7 @@ async function refreshModelCount() {
8699
9897
  document.getElementById("nav-models").addEventListener("click", () => {
8700
9898
  setNav("nav-models");
8701
9899
  focusProvider = null;
9900
+ panelBody.replaceChildren(panelLoading());
8702
9901
  void showModels();
8703
9902
  });
8704
9903
 
@@ -8781,6 +9980,11 @@ async function showSettings() {
8781
9980
  const panel = el("div", "panel");
8782
9981
  panel.appendChild(el("h2", null, t("settings.title")));
8783
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
+
8784
9988
  // A click on a button inside a summary toggles the disclosure as well as
8785
9989
  // running the button, so every action on a row gets this. Without it a
8786
9990
  // switch would flip and open the row underneath it, which reads as the page
@@ -8816,14 +10020,44 @@ async function showSettings() {
8816
10020
  if (long) body.appendChild(el("div", "mono small", value));
8817
10021
  if (hint) body.appendChild(el("div", "muted small", hint));
8818
10022
  row.appendChild(body);
8819
- panel.appendChild(row);
10023
+ target.appendChild(row);
8820
10024
  return body;
8821
10025
  };
8822
10026
 
8823
- // A labelled block inside an opened row: what the label names, printed as
8824
- // typed. Used for the permission rules and the settings files, both of
8825
- // which are lists someone reads once and closes again.
8826
- const listing = (label, text) => {
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
+
10057
+ // A labelled block inside an opened row: what the label names, printed as
10058
+ // typed. Used for the permission rules and the settings files, both of
10059
+ // which are lists someone reads once and closes again.
10060
+ const listing = (label, text) => {
8827
10061
  const group = el("div", "setting-list");
8828
10062
  group.appendChild(el("div", "muted small", label));
8829
10063
  const list = el("div", "mono small");
@@ -8861,64 +10095,140 @@ async function showSettings() {
8861
10095
  change.type = "button";
8862
10096
  change.addEventListener("click", openProjectPicker);
8863
10097
  field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
8864
- writesField(settings);
10098
+ permissionsField(settings);
8865
10099
 
8866
10100
  // A door, not a report: the numbers live on a page of their own, and the row
8867
10101
  // here carries no total of its own because working one out means walking
8868
10102
  // every session on disk — a cost this page would then pay on every paint,
8869
10103
  // for a figure most visits are not here for.
10104
+ target = groups.activity;
8870
10105
  const openUsage = el("button", "ghost", t("usage.open"));
8871
10106
  openUsage.type = "button";
8872
10107
  openUsage.addEventListener("click", () => {
8873
10108
  setNav("usage");
8874
10109
  void showUsage();
8875
10110
  });
8876
- 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
+ });
8877
10130
 
8878
10131
  /*
8879
10132
  * Memory sits here rather than in the sidebar, and only when a plugin has
8880
10133
  * actually registered it: a row for a page that does not exist would be a
8881
10134
  * dead end, and the memory plugin can be switched off.
8882
10135
  */
10136
+ target = groups.memory;
8883
10137
  for (const page of pluginPages.values()) {
8884
10138
  const open = el("button", "ghost", t("settings.memoryOpen"));
8885
10139
  open.type = "button";
8886
10140
  open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
8887
- 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
+ });
8888
10164
  }
8889
10165
 
8890
- rulesField(settings.permissions || {});
10166
+ target = groups.activity;
8891
10167
  auditField(settings.audit || { on: true, source: "user" });
10168
+ target = groups.advanced;
8892
10169
  filesField(settings.settingsPaths);
8893
10170
 
8894
10171
  /**
8895
- * 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.
8896
10174
  *
8897
- * Each kind used to get a card of its own, so a project with a handful of
8898
- * allow rules pushed every setting under it off the screen for a list that
8899
- * is read after something surprising happened and not before. The counts
8900
- * answer "is anything set here?" from the closed line; the rules themselves
8901
- * 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.
8902
10180
  */
8903
- function rulesField(rules) {
8904
- const kinds = ["allow", "ask", "deny"];
8905
- const summary = [];
8906
- 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"]) {
8907
10189
  if (rules[kind] && rules[kind].length) summary.push(kind + " " + rules[kind].length);
8908
10190
  }
8909
- const session = rules.sessionAllow || [];
8910
- if (session.length) summary.push(t("settings.sessionApproved") + " " + session.length);
8911
- if (!summary.length) return;
10191
+ const session = (rules.sessionAllow || []).length;
10192
+ if (session) summary.push(t("settings.sessionApproved") + " " + session);
8912
10193
 
8913
- const body = field(t("settings.rules"), summary.join(" · "), t("settings.rulesHint"));
8914
- for (const kind of kinds) {
8915
- if (rules[kind] && rules[kind].length) body.appendChild(listing(kind, rules[kind].join("\n")));
8916
- }
8917
- 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) {
8918
10216
  body.appendChild(listing(
8919
- t("settings.sessionApproved") + " — " + t("settings.notPersisted"),
8920
- session.join("\n")));
10217
+ chosen.length ? t("perms.mine") : t("perms.writes"),
10218
+ rootList.slice(0, shown).join("\n")));
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);
8921
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 })));
8922
10232
  }
8923
10233
 
8924
10234
  /**
@@ -8938,10 +10248,30 @@ async function showSettings() {
8938
10248
  function auditField(audit) {
8939
10249
  const mine = audit.source !== "project";
8940
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
+
8941
10270
  const body = field(
8942
10271
  t("settings.audit"),
8943
10272
  t(audit.on ? "settings.auditOnValue" : "settings.auditOffValue"),
8944
10273
  mine ? t(audit.on ? "settings.auditOn" : "settings.auditOff") : t("settings.auditProject"),
10274
+ open,
8945
10275
  toggle(audit.on, t("settings.audit"), (next) => {
8946
10276
  void api("/audit", { enabled: next }).then((result) => {
8947
10277
  if (result && result.error) addNotice(result.error, "err");
@@ -8957,8 +10287,40 @@ async function showSettings() {
8957
10287
  body.appendChild(el("div", "muted small", t("settings.auditFile", { path: audit.file })));
8958
10288
  }
8959
10289
 
8960
- const details = el("div");
8961
- 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
+ });
8962
10324
 
8963
10325
  /*
8964
10326
  * Taking the record away with you.
@@ -8973,76 +10335,7 @@ async function showSettings() {
8973
10335
  * "send me the audit" means, and building it out of one export per session
8974
10336
  * is not a thing anybody should be asked to do by hand.
8975
10337
  */
8976
- const exports = el("div", "row-actions");
8977
- for (const scope of ["session", "all"]) {
8978
- const link = el("a", "ghost", t("settings.auditExport" + (scope === "all" ? "All" : "")));
8979
- link.href = q("/audit/export?session=" + encodeURIComponent(scope === "all" ? "all" : activeId));
8980
- // The server names it; this only tells the browser to save rather than
8981
- // to navigate, which it would otherwise do with a JSON content type.
8982
- link.setAttribute("download", "");
8983
- exports.appendChild(link);
8984
- }
8985
- body.appendChild(exports);
8986
-
8987
- // Read on first open, not on every settings paint: the trail is a file
8988
- // read per session and the row is on screen whether or not anyone cares.
8989
- const row = body.parentNode;
8990
- let loaded = false;
8991
- row.addEventListener("toggle", () => {
8992
- if (!row.open || loaded) return;
8993
- loaded = true;
8994
- void getJson("/audit").then((result) => renderTrail(details, result));
8995
- });
8996
- }
8997
-
8998
- /**
8999
- * The trail, newest first, one line per question.
9000
- *
9001
- * A decision and the answer that closed it are one row, not two: they are
9002
- * one event as far as anybody reading this is concerned, and the pair is
9003
- * exactly what the old single-line format could not express — it recorded
9004
- * that something was asked and never what was answered.
9005
- */
9006
- function renderTrail(target, body) {
9007
- target.replaceChildren();
9008
- if (body && body.error) return void target.appendChild(panelError(body.error));
9009
-
9010
- const entries = (body && body.entries) || [];
9011
- if (!entries.length) {
9012
- return void target.appendChild(el("div", "muted small", t("settings.auditEmpty")));
9013
- }
9014
-
9015
- const answers = new Map();
9016
- for (const entry of entries) if (entry.kind === "answer") answers.set(entry.id, entry);
9017
-
9018
- const list = el("div", "mono small scroll-box");
9019
-
9020
- const decisions = entries.filter((entry) => entry.kind === "decision").reverse();
9021
- for (const entry of decisions.slice(0, 200)) {
9022
- const line = el("div", "trail-line");
9023
- const when = new Date(entry.at).toLocaleTimeString();
9024
- const answer = answers.get(entry.id);
9025
- // The answer replaces the behaviour it answered rather than following
9026
- // it: "ask → allowed" is one fact, and printing both reads as two.
9027
- const outcome = answer ? answer.outcome : entry.behavior;
9028
- const mark = el("span", outcome === "denied" || outcome === "unavailable" ? "err" : null,
9029
- outcome);
9030
- line.appendChild(el("span", "muted", when + " "));
9031
- line.appendChild(mark);
9032
- line.appendChild(document.createTextNode(" " + (entry.principal || entry.tool)));
9033
- const notes = [];
9034
- if (entry.rule) notes.push(entry.rule);
9035
- if (answer && answer.remembered) notes.push("remembered " + answer.remembered);
9036
- // An ask with no answer: the process stopped while the question was up.
9037
- if (entry.behavior === "ask" && !answer) notes.push("unanswered");
9038
- if (notes.length) line.appendChild(el("span", "muted", " " + notes.join(" · ")));
9039
- list.appendChild(line);
9040
- }
9041
- target.appendChild(list);
9042
- if (decisions.length > 200) {
9043
- target.appendChild(el("div", "muted small",
9044
- t("settings.auditMore", { n: decisions.length - 200 })));
9045
- }
10338
+ body.appendChild(auditExports(activeId));
9046
10339
  }
9047
10340
 
9048
10341
  /**
@@ -9076,72 +10369,9 @@ async function showSettings() {
9076
10369
  * rebuilt at launch, so a control over it would be a control that silently
9077
10370
  * reverts.
9078
10371
  */
9079
- function writesField(settings) {
9080
- const workspace = settings.workspace || {};
9081
- if (workspace.mode !== "workspace-write") {
9082
- return void field(t("settings.writes"), workspace.mode || "—");
9083
- }
9084
-
9085
- const all = workspace.writableRoots || [];
9086
- const chosen = settings.configuredRoots || [];
9087
- const derived = all.filter((root) => chosen.indexOf(root) < 0);
9088
-
9089
- const body = field(
9090
- t("settings.writes"),
9091
- workspace.mode + " · " + all.length + " roots",
9092
- all.length + " writable roots — a policy gate on tool arguments, not a sandbox: a shell command can still reach any path you can.",
9093
- );
9094
-
9095
- // Yours, and editable. A textarea rather than a list with buttons because
9096
- // the thing being edited is a handful of paths, and typing one is what
9097
- // someone came here to do.
9098
- const mine = el("div", "setting-list");
9099
- mine.appendChild(el("div", "muted small", "Roots you configured"));
9100
- body.appendChild(mine);
9101
-
9102
- const box = el("textarea", "boxed");
9103
- box.value = chosen.join("\n");
9104
- box.rows = Math.max(2, chosen.length + 1);
9105
- box.spellcheck = false;
9106
- box.style.width = "100%";
9107
- box.placeholder = "One absolute path per line";
9108
- mine.appendChild(box);
9109
-
9110
- const save = el("button", "ghost", "Save roots");
9111
- save.type = "button";
9112
- save.addEventListener("click", () => {
9113
- save.disabled = true;
9114
- const roots = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
9115
- void api("/settings/writable-roots", { roots }).then((result) => {
9116
- save.disabled = false;
9117
- if (result && result.error) return void addNotice(result.error, "err");
9118
- // Said out loud, because the row above will go on showing the old
9119
- // list until then and someone would reasonably read that as a failure.
9120
- addNotice("saved — takes effect the next time the app starts");
9121
- });
9122
- });
9123
- mine.appendChild(save);
9124
-
9125
- if (derived.length) {
9126
- const theirs = el("div", "setting-list");
9127
- // "more" only reads right when there were some above it. With nothing
9128
- // configured — the common case — every one of them is derived.
9129
- theirs.appendChild(el("div", "muted small",
9130
- (chosen.length ? derived.length + " more come" : "All " + derived.length + " come") +
9131
- " from installed plugins and are rebuilt at launch:"));
9132
- // Scrolls in its own box rather than growing the page. Expanded, forty
9133
- // cache paths is the same wall of text this row was collapsed to escape
9134
- // — the click should reveal the list, not restore the problem.
9135
- const list = el("div", "mono small scroll-box");
9136
- list.style.opacity = ".7";
9137
- list.textContent = derived.join("\n");
9138
- theirs.appendChild(list);
9139
- body.appendChild(theirs);
9140
- }
9141
- }
9142
-
9143
10372
  // Sync, above analytics, because it is the one on this page that decides
9144
10373
  // where the work goes rather than whether a counter is incremented.
10374
+ target = groups.sync;
9145
10375
  const sync = await getJson("/sync");
9146
10376
  if (sync && !sync.error) {
9147
10377
  // Three states, not two. Off and "on but signed out" both send nothing,
@@ -9203,6 +10433,7 @@ async function showSettings() {
9203
10433
  // Last, and only when this build could actually send something. A switch
9204
10434
  // that does nothing is worse than no switch: it invites someone to believe
9205
10435
  // they turned something off that was never on.
10436
+ target = groups.activity;
9206
10437
  const analytics = await getJson("/analytics");
9207
10438
  if (analytics && !analytics.error && analytics.available) {
9208
10439
  field(
@@ -9214,6 +10445,17 @@ async function showSettings() {
9214
10445
  }),
9215
10446
  );
9216
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
+ }
9217
10459
  panelBody.appendChild(panel);
9218
10460
  }
9219
10461
 
@@ -9230,8 +10472,672 @@ async function showSettings() {
9230
10472
  * counted twice, nothing survives a session that was deleted, and a run
9231
10473
  * started from the terminal is in the totals the same as one started here.
9232
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
+
9233
10846
  /** The window the usage page is asking about, for this page load. */
9234
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
+ }
9235
11141
 
9236
11142
  /**
9237
11143
  * What was spent over time, one line per model.
@@ -9397,7 +11303,13 @@ function usageChart(body, models) {
9397
11303
  }
9398
11304
 
9399
11305
  async function showUsage() {
9400
- 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);
9401
11313
  panelBody.replaceChildren();
9402
11314
  if (body.error) return void panelBody.appendChild(panelError(body.error));
9403
11315
 
@@ -9427,6 +11339,13 @@ async function showUsage() {
9427
11339
  * figures above it did not would be lying about one of them.
9428
11340
  */
9429
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");
9430
11349
  const ranges = el("div", "seg");
9431
11350
  for (const id of ["7d", "30d", "90d", "all"]) {
9432
11351
  const button = el("button", "seg-item" + (id === picked ? " on" : ""), t("usage.range." + id));
@@ -9438,7 +11357,31 @@ async function showUsage() {
9438
11357
  });
9439
11358
  ranges.appendChild(button);
9440
11359
  }
9441
- 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);
9442
11385
 
9443
11386
  // Prompt tokens only. The cache share is a share of what was sent, and
9444
11387
  // nothing that was generated could have come from a cache.
@@ -9773,52 +11716,259 @@ async function showMemoryPage(id) {
9773
11716
  if (result.error && !result.entries) return void panelBody.appendChild(panelError(result.error));
9774
11717
  const panel = el("div", "panel memory-panel"); panelBody.appendChild(panel);
9775
11718
  panel.appendChild(el("h2", "", t("memory.title")));
9776
- panel.appendChild(el("p", "muted", t("memory.intro")));
9777
- panel.appendChild(el("p", "mono small memory-path", result.location));
9778
- 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"));
9779
- panel.appendChild(status);
9780
- if (result.lastSync) panel.appendChild(el("p", "muted small", t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) })));
9781
- 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
+ }
9782
11779
  const act = async (input) => {
9783
11780
  try {
9784
11781
  const reply = await api("/plugin-page", { page: id, session, input }, 120000);
9785
- 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 });
9786
11783
  await showMemoryPage(id);
9787
11784
  } catch (error) {
9788
11785
  if (activeId === session && shownPanel === "nav-plugin-" + id) {
9789
11786
  await showMemoryPage(id);
9790
- 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" });
9791
11794
  }
9792
11795
  }
9793
11796
  };
9794
- 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
+ }
9795
11803
  const toggle = el("button", "ghost", result.enabled ? t("memory.turnOff") : t("memory.turnOn"));
9796
11804
  toggle.setAttribute("role", "switch"); toggle.setAttribute("aria-label", t("memory.switchLabel")); toggle.setAttribute("aria-checked", String(result.enabled));
9797
11805
  toggle.disabled = !result.enabled && (!result.signedIn || result.accountChanged);
9798
11806
  toggle.onclick = async () => { toggle.disabled = true; await act({ action: "enable", enabled: !result.enabled }); toggle.disabled = false; };
9799
- 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);
9800
11810
  if (result.signedIn) {
9801
11811
  const sync = el("button", "ghost", t("memory.syncNow")); sync.disabled = !result.ready || result.syncing;
9802
11812
  sync.onclick = async () => { sync.disabled = true; status.textContent = t("memory.syncing"); await act({ action: "sync" }); sync.disabled = false; };
9803
11813
  controls.appendChild(sync);
9804
11814
  }
9805
11815
  const refresh = el("button", "ghost", t("memory.refresh")); refresh.onclick = () => { void showMemoryPage(id); }; controls.appendChild(refresh);
9806
- 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.
9807
11819
  for (const conflict of result.conflicts || []) {
9808
- const row = el("section", "row-card"); row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
9809
- for (const [label, note] of [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]]) {
9810
- 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);
9811
11834
  }
9812
- for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")]]) {
9813
- 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);
9814
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);
9815
11866
  panel.appendChild(row);
9816
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);
9817
11956
  const editor = (note, extra) => {
9818
11957
  const form = el("form", "memory-editor"), summary = el("input", ""), body = el("textarea", ""), tags = el("input", "");
9819
11958
  summary.placeholder = t("memory.summaryPlaceholder"); summary.value = note?.summary || ""; summary.required = true;
9820
11959
  body.placeholder = t("memory.bodyPlaceholder"); body.value = note?.body || ""; body.rows = 6; body.required = true;
9821
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;
9822
11972
  /*
9823
11973
  * A label each, and a line saying what the field is for.
9824
11974
  *
@@ -9842,6 +11992,8 @@ async function showMemoryPage(id) {
9842
11992
  form.appendChild(labelled(summary, t("memory.summaryLabel"), t("memory.summaryHint")));
9843
11993
  form.appendChild(labelled(body, t("memory.bodyLabel"), t("memory.bodyHint")));
9844
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")));
9845
11997
  const actions = el("div", "memory-actions");
9846
11998
  const save = el("button", "ghost", note ? t("memory.save") : t("memory.add")); save.type = "submit"; actions.appendChild(save);
9847
11999
  // Delete belongs beside Save, not stranded under the card: they are the two
@@ -9849,21 +12001,29 @@ async function showMemoryPage(id) {
9849
12001
  if (extra) actions.appendChild(extra);
9850
12002
  form.appendChild(actions);
9851
12003
  form.onsubmit = async (event) => { event.preventDefault(); save.disabled = true;
9852
- 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;
9853
12005
  };
9854
12006
  return form;
9855
12007
  };
9856
- const add = el("details", "row-card memory-note");
9857
- const addHead = el("summary", "");
9858
- addHead.appendChild(el("span", "chev"));
9859
- addHead.appendChild(el("span", "memory-note-name", t("memory.add")));
9860
- add.appendChild(addHead); add.appendChild(editor()); panel.appendChild(add);
9861
- 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);
9862
12014
  const list = el("div", ""); panel.appendChild(list);
9863
12015
  const draw = () => {
9864
12016
  list.replaceChildren(); const query = search.value.toLowerCase();
9865
- const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ")).toLowerCase().includes(query));
9866
- 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
+ }
9867
12027
  for (const note of entries) {
9868
12028
  const row = el("details", "row-card memory-note");
9869
12029
  /*
@@ -9880,14 +12040,71 @@ async function showMemoryPage(id) {
9880
12040
  head.appendChild(el("span", "chev"));
9881
12041
  head.appendChild(el("span", "memory-note-name", note.summary));
9882
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));
9883
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
+ }
9884
12077
  const remove = el("button", "ghost", t("memory.delete")); remove.type = "button";
9885
- 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
+ };
9886
12082
  row.appendChild(editor(note, remove));
9887
12083
  list.appendChild(row);
9888
12084
  }
9889
12085
  };
9890
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);
9891
12108
  }
9892
12109
 
9893
12110
  /**
@@ -9899,7 +12116,7 @@ async function showMemoryPage(id) {
9899
12116
  * sessions have no reason to open. It keeps the row it came from lit, so the
9900
12117
  * sidebar still says where in the app this is.
9901
12118
  */
9902
- const SUB_PANELS = { usage: "nav-settings" };
12119
+ const SUB_PANELS = { usage: "nav-settings", audit: "nav-settings", permissions: "nav-settings" };
9903
12120
 
9904
12121
  /** Which panel the panel view is currently showing, sub-panels included. */
9905
12122
  let shownPanel = null;
@@ -9988,6 +12205,8 @@ function repaintPanel() {
9988
12205
  else if (shownPanel === "nav-routines") void showRoutines();
9989
12206
  else if (shownPanel === "nav-settings") void showSettings();
9990
12207
  else if (shownPanel === "usage") void showUsage();
12208
+ else if (shownPanel === "audit") void showAudit();
12209
+ else if (shownPanel === "permissions") void showPermissions();
9991
12210
  else if (shownPanel?.startsWith("nav-plugin-")) void showMemoryPage(shownPanel.slice("nav-plugin-".length));
9992
12211
  }
9993
12212
 
@@ -10687,12 +12906,16 @@ ${ANALYTICS_STUB}
10687
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>
10688
12907
  <div class="menu" hidden></div>
10689
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>
10690
12913
  <div class="picker" id="mode-picker">
10691
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>
10692
12915
  <div class="menu" hidden></div>
10693
12916
  </div>
10694
12917
  <span class="composer-spacer"></span>
10695
- <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>
10696
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>
10697
12920
  </div>
10698
12921
  </div>
@@ -10742,7 +12965,7 @@ ${ANALYTICS_STUB}
10742
12965
  </dialog>
10743
12966
 
10744
12967
  <dialog id="palette">
10745
- <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>
10746
12969
  <div id="cmdk-list"></div>
10747
12970
  <div class="cmdk-foot">
10748
12971
  <span><kbd>↑↓</kbd><span data-t="palette.moveHint"></span></span>
@@ -10833,8 +13056,11 @@ ${ANALYTICS_STUB}
10833
13056
 
10834
13057
  <dialog id="ask">
10835
13058
  <div class="ask-head">
10836
- <div class="ask-tags"><span id="ask-tag" class="ask-tag"></span><span id="ask-tool" class="ask-tool"></span></div>
10837
- <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>
10838
13064
  <div id="ask-meta" class="ask-meta"></div>
10839
13065
  </div>
10840
13066
  <div id="ask-body" class="ask-body"></div>
@@ -10848,7 +13074,7 @@ ${ANALYTICS_STUB}
10848
13074
  <button data-answer="session" data-scope="session"><span data-t="ask.session"></span></button>
10849
13075
  <button data-answer="project" data-scope="project"><span data-t="ask.project"></span></button>
10850
13076
  </div>
10851
- <div id="ask-scope" class="ask-scope"></div>
13077
+ <div id="ask-scope" class="ask-scope" hidden></div>
10852
13078
  </dialog>
10853
13079
 
10854
13080
  <script>