@oharness/server 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n.js +473 -10
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/markdown.js +512 -61
- package/dist/markdown.js.map +1 -1
- package/dist/page.js +2599 -373
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +7 -0
- package/dist/prefs.js +3 -0
- package/dist/prefs.js.map +1 -1
- package/dist/server.d.ts +14 -1
- package/dist/server.js +264 -42
- package/dist/server.js.map +1 -1
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +96 -0
- package/dist/theme.js.map +1 -0
- package/package.json +2 -2
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
|
-
|
|
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(--
|
|
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(--
|
|
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;
|
|
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
|
|
1296
|
-
|
|
1297
|
-
|
|
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:
|
|
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
|
|
1305
|
-
.fold-row .more:hover,.fold-row .fold.menu-open
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1514
|
+
.cmdk-empty{padding:12px 14px;font-size:12.5px;color:var(--dim)}
|
|
1344
1515
|
.cmdk-foot{
|
|
1345
|
-
display:flex;gap:
|
|
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:
|
|
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(--
|
|
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(--
|
|
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(--
|
|
1616
|
-
.input-wrap textarea::selection{background
|
|
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:
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
.
|
|
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.
|
|
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-
|
|
1802
|
-
.ask-
|
|
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;
|
|
2055
|
+
font-size:13.5px;font-weight:500;white-space:pre-wrap;
|
|
1805
2056
|
}
|
|
1806
|
-
.ask-
|
|
1807
|
-
.ask-
|
|
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
|
|
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-
|
|
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:
|
|
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:.
|
|
1859
|
-
padding:0
|
|
1860
|
-
}
|
|
1861
|
-
.
|
|
1862
|
-
|
|
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",
|
|
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
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
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.
|
|
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
|
-
|
|
4547
|
-
|
|
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.
|
|
4555
|
-
*
|
|
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 = {
|
|
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.
|
|
4566
|
-
view.
|
|
4567
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
4579
|
-
view.meta
|
|
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
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
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.
|
|
4600
|
-
else if (typeof input.pattern === "string") view.
|
|
4601
|
-
|
|
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
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
const
|
|
4624
|
-
|
|
4625
|
-
askBody.
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
4712
|
-
button.addEventListener("blur",
|
|
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("
|
|
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
|
-
|
|
7915
|
-
|
|
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
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
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 (
|
|
7950
|
-
const head = el("div", "menu-head");
|
|
7951
|
-
if (
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
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
|
-
|
|
10023
|
+
target.appendChild(row);
|
|
8820
10024
|
return body;
|
|
8821
10025
|
};
|
|
8822
10026
|
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
8898
|
-
*
|
|
8899
|
-
*
|
|
8900
|
-
*
|
|
8901
|
-
* are
|
|
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
|
|
8904
|
-
const
|
|
8905
|
-
const
|
|
8906
|
-
|
|
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
|
|
8911
|
-
if (!summary.length) return;
|
|
10191
|
+
const session = (rules.sessionAllow || []).length;
|
|
10192
|
+
if (session) summary.push(t("settings.sessionApproved") + " " + session);
|
|
8912
10193
|
|
|
8913
|
-
const
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
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("
|
|
8920
|
-
|
|
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
|
-
|
|
8961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9777
|
-
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
|
|
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
|
-
|
|
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", "");
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9809
|
-
|
|
9810
|
-
|
|
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
|
-
|
|
9813
|
-
|
|
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
|
-
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
|
|
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)
|
|
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 = () => {
|
|
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="
|
|
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
|
-
<
|
|
10837
|
-
<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>
|