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