@oharness/server 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n.js +72 -10
- package/dist/i18n.js.map +1 -1
- package/dist/icons.js +46 -0
- package/dist/icons.js.map +1 -1
- package/dist/page.js +1248 -96
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +110 -5
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/page.js
CHANGED
|
@@ -26,6 +26,11 @@ const STYLE = String.raw `
|
|
|
26
26
|
--add:#065f46; --add-bg:#ecfdf5; --del:#991b1b; --del-bg:#fef2f2;
|
|
27
27
|
--hover:#00000008; --sel:#00000012; --focus:#111827; --ring:#1118271a;
|
|
28
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;
|
|
29
34
|
--kw:#7c3aed; --str:#047857; --com:#94a3b8; --num:#b45309;
|
|
30
35
|
}
|
|
31
36
|
/* The dark palette, reached two ways: the system asked for it and nothing
|
|
@@ -40,6 +45,7 @@ const STYLE = String.raw `
|
|
|
40
45
|
--add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
|
|
41
46
|
--hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
|
|
42
47
|
--field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
|
|
48
|
+
--mention-lift:0 0 0 1px #ffffff1f, 0 1px 3px #00000073;
|
|
43
49
|
--kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
|
|
44
50
|
}
|
|
45
51
|
}
|
|
@@ -199,6 +205,14 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
199
205
|
}
|
|
200
206
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
201
207
|
:root[data-theme="dark"] .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
|
|
208
|
+
/* The account menu is as wide as the account row it opens from, edge to edge.
|
|
209
|
+
Sized for a session row's three commands, it came out a third narrower than
|
|
210
|
+
the row under it, so the two read as unrelated boxes; the width is set on
|
|
211
|
+
open from the row itself, and this lifts the caps that would clamp it. */
|
|
212
|
+
.row-menu.fit-anchor{min-width:0;max-width:none}
|
|
213
|
+
.row-menu.account-menu{
|
|
214
|
+
padding:10px;max-height:calc(100vh - 24px);overflow-y:auto;
|
|
215
|
+
}
|
|
202
216
|
/*
|
|
203
217
|
* Destructive is carried by the label's colour, not by a filled red bar. A
|
|
204
218
|
* solid block reads as an alert that has already happened rather than as a
|
|
@@ -217,8 +231,24 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
217
231
|
.row-menu .menu-head{
|
|
218
232
|
padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
|
|
219
233
|
}
|
|
234
|
+
/* A section label, not a surface: the fill belongs to the account's facts
|
|
235
|
+
above, and on a one-word caption it read as a second card pressed against
|
|
236
|
+
Dashboard. Space above it does the separating instead. */
|
|
237
|
+
.row-menu .menu-head.menu-section{
|
|
238
|
+
margin:10px 0 2px;padding:4px 10px;background:transparent;
|
|
239
|
+
}
|
|
220
240
|
.menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
|
|
221
241
|
.menu-head-sub{margin-top:2px;font-size:12px;color:var(--dim)}
|
|
242
|
+
.menu-billing-actions{display:flex;gap:7px;margin-top:10px}
|
|
243
|
+
.menu-billing-action{
|
|
244
|
+
flex:1;min-height:30px;padding:6px 10px;border:0;border-radius:7px;
|
|
245
|
+
background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line);
|
|
246
|
+
font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;
|
|
247
|
+
}
|
|
248
|
+
.menu-billing-action:hover{background:var(--hover)}
|
|
249
|
+
.menu-billing-action.primary{background:var(--accent);color:var(--bg);box-shadow:none}
|
|
250
|
+
.menu-billing-action.primary:hover{opacity:.88}
|
|
251
|
+
.menu-billing-action:disabled{cursor:not-allowed;opacity:.45}
|
|
222
252
|
/* One entry carrying the accent, for the one that is an offer rather than a
|
|
223
253
|
destination. var(--field) was too close to the menu's own surface to read as
|
|
224
254
|
anything in the light theme — a highlight nobody can see is worse than none,
|
|
@@ -323,6 +353,20 @@ main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
|
323
353
|
padding:0 10px;font:inherit;font-size:12.5px;
|
|
324
354
|
}
|
|
325
355
|
.menu-search input:focus{outline:none;background:var(--field-focus)}
|
|
356
|
+
/* Eighty-one skills with no way to narrow them is a list nobody reads to the
|
|
357
|
+
end. Matches the name and the description both, because the name is terse
|
|
358
|
+
and chosen by whoever wrote the skill. */
|
|
359
|
+
/* Quiet: it is a provenance note on a row whose subject is the name. */
|
|
360
|
+
.mk-tag{
|
|
361
|
+
margin-left:7px;padding:1px 5px;border-radius:4px;vertical-align:1px;
|
|
362
|
+
background:var(--sel);color:var(--dim);font-size:10px;font-weight:500;
|
|
363
|
+
letter-spacing:.02em;text-transform:uppercase;
|
|
364
|
+
}
|
|
365
|
+
.mk-search{
|
|
366
|
+
width:100%;height:32px;margin:10px 0 2px;border:0;border-radius:8px;
|
|
367
|
+
background:var(--field);color:var(--fg);padding:0 11px;font:inherit;font-size:13px;
|
|
368
|
+
}
|
|
369
|
+
.mk-search:focus{outline:none;background:var(--field-focus)}
|
|
326
370
|
.menu-body{overflow-y:auto;max-height:min(46vh,340px)}
|
|
327
371
|
.menu-footer{padding:5px 3px 3px;box-shadow:0 -1px 0 var(--line)}
|
|
328
372
|
.menu-item.cursor{background:var(--hover)}
|
|
@@ -391,16 +435,30 @@ svg.vglyph{width:15px;height:15px;opacity:1;
|
|
|
391
435
|
the composer bar's 2px does not provide. */
|
|
392
436
|
.menu-group .vmark,.menu-group .vmono,.menu-group .vglyph{width:13px;height:13px;font-size:8px}
|
|
393
437
|
.picker-btn .vmark,.picker-btn .vmono,.picker-btn .vglyph{width:14px;height:14px;margin-right:5px}
|
|
438
|
+
/* The mode's glyph: a bare stroke, so it takes the row's icon weight and, on
|
|
439
|
+
the bar, the vendor mark's size and gap so the two pickers line up. */
|
|
440
|
+
.menu-item .mode-mark{opacity:.7}
|
|
441
|
+
.menu-item.on .mode-mark,.menu-item.cursor .mode-mark{opacity:1}
|
|
442
|
+
.picker-btn .mode-mark{width:14px;height:14px;margin-right:5px;opacity:.85}
|
|
394
443
|
/* On a card the row is a title over a line of description, so the mark is
|
|
395
444
|
sized against the block rather than against the type, and sits on the
|
|
396
445
|
title's line instead of floating in the middle of the two. */
|
|
446
|
+
/* Centred on the naming block, not on the first line of it. A 20px tile
|
|
447
|
+
* beside a title over a description is read as the row's own mark, and a
|
|
448
|
+
* mark that sits on the title alone leaves the row bottom-heavy — the same
|
|
449
|
+
* complaint as the margin above, one level in. */
|
|
397
450
|
details.provider>summary .vmark,details.provider>summary .vmono,details.provider>summary .vglyph,
|
|
398
451
|
.pp-item .vmark,.pp-item .vmono,.pp-item .vglyph{
|
|
399
|
-
width:20px;height:20px;border-radius:5px;font-size:11px;
|
|
452
|
+
width:20px;height:20px;border-radius:5px;font-size:11px;
|
|
400
453
|
}
|
|
401
454
|
details.provider>summary .vmark,.pp-item .vmark,
|
|
402
455
|
details.provider>summary .vglyph,.pp-item .vglyph{padding:2.5px}
|
|
403
456
|
details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
457
|
+
.menu-head{
|
|
458
|
+
padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;
|
|
459
|
+
text-transform:uppercase;color:var(--dim);
|
|
460
|
+
}
|
|
461
|
+
.menu-head:first-child{padding-top:4px}
|
|
404
462
|
.menu-group{
|
|
405
463
|
display:flex;align-items:center;gap:7px;width:100%;border:0;background:transparent;
|
|
406
464
|
font:inherit;font-size:10.5px;color:var(--dim);padding:7px 8px 5px;cursor:pointer;
|
|
@@ -463,8 +521,18 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
|
463
521
|
* Tabs are sessions; the drawer beside them shows one artifact whichever tab
|
|
464
522
|
* is in front. Spanning both left a band of empty chrome above the drawer that
|
|
465
523
|
* belonged to neither.
|
|
524
|
+
*
|
|
525
|
+
* The strip is the shelf the tabs stand on, in the sidebar's grey, and the
|
|
526
|
+
* front tab is cut from the page under it — the shape every browser uses.
|
|
527
|
+
* It used to be the other way round: a transparent strip on the white
|
|
528
|
+
* transcript with the front tab as a grey pill, so the row read as part of
|
|
529
|
+
* the conversation rather than as the chrome above it. No bottom padding,
|
|
530
|
+
* or a grey line would sit between the front tab and the page it is part of.
|
|
466
531
|
*/
|
|
467
|
-
#tabs{
|
|
532
|
+
#tabs{
|
|
533
|
+
display:flex;align-items:center;gap:2px;padding:6px 12px 0;overflow-x:auto;flex:0 0 auto;
|
|
534
|
+
background:var(--panel);
|
|
535
|
+
}
|
|
468
536
|
#tabs:empty{display:none}
|
|
469
537
|
.tab{
|
|
470
538
|
display:flex;align-items:center;gap:7px;max-width:190px;height:30px;padding:0 6px 0 10px;
|
|
@@ -472,7 +540,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
|
472
540
|
font:inherit;font-size:12.5px;cursor:pointer;flex:0 0 auto;
|
|
473
541
|
}
|
|
474
542
|
.tab:hover{background:var(--hover);color:var(--fg)}
|
|
475
|
-
.tab.on{background:var(--
|
|
543
|
+
.tab.on{background:var(--bg);color:var(--fg)}
|
|
476
544
|
.tab .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
477
545
|
/* Which project a tab is in, shown only when the open tabs span more than one:
|
|
478
546
|
two transcripts called "Untitled" in different trees are otherwise the same
|
|
@@ -533,7 +601,7 @@ body.panelled #composer,body.panelled #jump,body.panelled #changes-btn{display:n
|
|
|
533
601
|
border-left:2px solid transparent;
|
|
534
602
|
}
|
|
535
603
|
.file:hover{background:var(--bg)}
|
|
536
|
-
.file.active{background:var(--
|
|
604
|
+
.file.active{background:var(--sel)}
|
|
537
605
|
.file .p{font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
|
538
606
|
.file .n{font-size:11px;font-family:ui-monospace,monospace}
|
|
539
607
|
.file .plus{color:var(--ok)} .file .minus{color:var(--err)}
|
|
@@ -609,7 +677,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
609
677
|
/* Disabled means "not yet", not "never": it stays legible, and the row says
|
|
610
678
|
why. Hiding it instead left people looking for a control that exists. */
|
|
611
679
|
.switch:disabled{opacity:.45;cursor:default}
|
|
612
|
-
.switch:focus-visible{outline:
|
|
680
|
+
.switch:focus-visible{outline:none;filter:brightness(.9)}
|
|
613
681
|
.share-btn{
|
|
614
682
|
border:1px solid var(--line);background:var(--field);color:var(--fg);
|
|
615
683
|
border-radius:5px;padding:2px 8px;font:inherit;font-size:11px;cursor:pointer;
|
|
@@ -647,7 +715,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
647
715
|
transcript look like a dialog, and next to the tool cards it sits between —
|
|
648
716
|
which have none — it was the only thing drawing a box around itself. */
|
|
649
717
|
.art-card:hover{background:var(--sel)}
|
|
650
|
-
.art-card:focus-visible{outline:
|
|
718
|
+
.art-card:focus-visible{outline:none;background:var(--sel)}
|
|
651
719
|
.art-card-mark{
|
|
652
720
|
display:flex;align-items:center;justify-content:center;flex:0 0 auto;
|
|
653
721
|
width:28px;height:28px;border-radius:7px;background:var(--field);color:var(--fg);
|
|
@@ -769,10 +837,10 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
769
837
|
/* Filled, not outlined — the same shape .art-search uses. A box drawn with a
|
|
770
838
|
* line around it is the one thing this site does not do, and three of them
|
|
771
839
|
* stacked made the editor look like a form bolted onto the page rather than
|
|
772
|
-
* part of the note.
|
|
773
|
-
*
|
|
840
|
+
* part of the note. Focus deepens the fill rather than drawing a ring: no
|
|
841
|
+
* border marks a focused thing anywhere here. */
|
|
774
842
|
.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}
|
|
775
|
-
.memory-panel input:focus,.memory-panel textarea:focus{outline:
|
|
843
|
+
.memory-panel input:focus,.memory-panel textarea:focus{outline:none;background:var(--field-focus)}
|
|
776
844
|
.memory-panel textarea{resize:vertical;min-height:130px}
|
|
777
845
|
.memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
|
|
778
846
|
.memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
|
|
@@ -816,7 +884,7 @@ details.setting[open]>summary{border-radius:10px 10px 0 0}
|
|
|
816
884
|
details.setting[open]>summary .chev{transform:rotate(90deg)}
|
|
817
885
|
details.setting>summary .ghost{flex:0 0 auto;height:26px;font-size:12px;margin-left:0}
|
|
818
886
|
details.setting>summary .switch{flex:0 0 auto}
|
|
819
|
-
details.setting>summary:focus-visible{outline:
|
|
887
|
+
details.setting>summary:focus-visible{outline:none;background:var(--hover)}
|
|
820
888
|
/*
|
|
821
889
|
* The artifacts panel: a search field over the whole catalogue.
|
|
822
890
|
*
|
|
@@ -926,8 +994,17 @@ details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-of
|
|
|
926
994
|
.section{background:var(--panel);border-radius:12px;overflow:hidden}
|
|
927
995
|
.section details.provider + details.provider{box-shadow:0 -1px 0 var(--line)}
|
|
928
996
|
details.provider{padding:0}
|
|
929
|
-
|
|
930
|
-
|
|
997
|
+
/* Padding on all four sides and no margin, because the row is measured
|
|
998
|
+
* between the dividers and not around the box that draws the hover.
|
|
999
|
+
*
|
|
1000
|
+
* The .panel .row-head rule carries a 14px bottom margin for the single
|
|
1001
|
+
* heading row it was written for, and a provider card's summary is a
|
|
1002
|
+
* .row-head too. It
|
|
1003
|
+
* landed outside the summary and inside the divider, so every card had 12px
|
|
1004
|
+
* of air above its content and 26px below — the whole row reading as pinned
|
|
1005
|
+
* to its top edge, which is what it was. */
|
|
1006
|
+
.panel details.provider>summary,details.provider>summary{
|
|
1007
|
+
padding:14px;cursor:pointer;list-style:none;margin:0;gap:10px;
|
|
931
1008
|
}
|
|
932
1009
|
details.provider>summary::-webkit-details-marker{display:none}
|
|
933
1010
|
.naming{flex:1;min-width:0}
|
|
@@ -963,10 +1040,20 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
963
1040
|
separate the account from the list — a hairline as well is a second answer
|
|
964
1041
|
to a question the whitespace had settled, and it is the only line left in a
|
|
965
1042
|
sidebar that has none. */
|
|
1043
|
+
/* A column, not a row. Signed in this used to hold the account button and
|
|
1044
|
+
nothing else, and now a subscriber gets a Top up row above it — the thing
|
|
1045
|
+
a subscriber does most often here, which had been the fourth entry of a
|
|
1046
|
+
menu behind the name. */
|
|
966
1047
|
#account{
|
|
967
|
-
display:flex;align-items:
|
|
1048
|
+
display:flex;flex-direction:column;align-items:stretch;gap:2px;
|
|
968
1049
|
flex:0 0 auto;padding:8px;
|
|
969
1050
|
}
|
|
1051
|
+
/* 10px across rather than the nav rows' 8: the account row under it starts
|
|
1052
|
+
with a 24px avatar inset 6px, and at 10px this row's 16px icon lands on
|
|
1053
|
+
the avatar's centre line rather than 4px to the left of it. The balance
|
|
1054
|
+
sits in the aside slot the nav rows keep for a count, because it is what
|
|
1055
|
+
decides whether to click. */
|
|
1056
|
+
#account .topup{width:100%;padding:0 10px}
|
|
970
1057
|
/* Signed out this holds one nav row and nothing else, so the padding that
|
|
971
1058
|
frames an account goes away and the row sits flush with the ones above it.
|
|
972
1059
|
8px across, matching the nav list above, so the icon lands on the same
|
|
@@ -977,7 +1064,8 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
977
1064
|
taller than the nav rows above: a footer is allowed to be the roomiest row
|
|
978
1065
|
in the sidebar, and this one has to hold a click aimed at from the corner. */
|
|
979
1066
|
#account.out{padding:4px 8px 10px}
|
|
980
|
-
#account
|
|
1067
|
+
#account .nav-item{width:100%}
|
|
1068
|
+
#account.out .nav-item{height:34px}
|
|
981
1069
|
/* The whole row is the control. It used to be a label with a Sign out button
|
|
982
1070
|
parked beside it, which gave the rarest action in the sidebar the most
|
|
983
1071
|
reliable target in it, and left the name — the thing worth clicking, because
|
|
@@ -1045,15 +1133,15 @@ button.signin-site:disabled{opacity:.4;cursor:default}
|
|
|
1045
1133
|
* The site's pricing page settled this and says why in its own stylesheet: the
|
|
1046
1134
|
* offers should read as columns of type rather than as a row of frames, and on
|
|
1047
1135
|
* a screen that is mostly numbers the frames are the loudest thing on it. So
|
|
1048
|
-
* the fill goes quiet and the recommended rung
|
|
1049
|
-
*
|
|
1136
|
+
* the fill goes quiet and the recommended rung takes a deeper fill. No outline
|
|
1137
|
+
* marks anything here, the recommended rung included.
|
|
1050
1138
|
*/
|
|
1051
1139
|
button.pw-plan{
|
|
1052
1140
|
display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
|
|
1053
1141
|
min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
|
|
1054
1142
|
background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
|
|
1055
1143
|
}
|
|
1056
|
-
button.pw-plan.featured{
|
|
1144
|
+
button.pw-plan.featured{background:var(--sel)}
|
|
1057
1145
|
.pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
1058
1146
|
button.pw-plan:hover{background:var(--sel)}
|
|
1059
1147
|
button.pw-plan.featured:hover{background:var(--hover)}
|
|
@@ -1116,9 +1204,16 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
|
|
|
1116
1204
|
wrong for a list that wants two columns. */
|
|
1117
1205
|
#market{width:min(860px,94vw);max-width:min(860px,94vw);max-height:76vh}
|
|
1118
1206
|
#market[open]{display:flex;flex-direction:column}
|
|
1119
|
-
.mk-head{padding:18px 20px 10px}
|
|
1120
|
-
#mk-list{overflow-y:auto;padding:2px 12px
|
|
1121
|
-
.
|
|
1207
|
+
.mk-head{padding:18px 20px 10px;position:relative}
|
|
1208
|
+
#mk-list{overflow-y:auto;padding:2px 12px 14px;flex:1}
|
|
1209
|
+
/* Where every other window puts it. A "Done" under the list meant scrolling
|
|
1210
|
+
past eighty rows to leave, for a dialog that changes nothing on the way out. */
|
|
1211
|
+
.mk-close{
|
|
1212
|
+
position:absolute;top:14px;right:14px;width:28px;height:28px;border-radius:8px;
|
|
1213
|
+
border:0;background:transparent;color:var(--dim);display:grid;place-items:center;cursor:pointer;
|
|
1214
|
+
}
|
|
1215
|
+
.mk-close:hover{background:var(--hover);color:var(--fg)}
|
|
1216
|
+
.mk-close .ico-sm{width:14px;height:14px;opacity:1}
|
|
1122
1217
|
.mk-group{
|
|
1123
1218
|
font-size:10.5px;color:var(--dim);letter-spacing:.04em;text-transform:uppercase;
|
|
1124
1219
|
padding:12px 8px 6px;
|
|
@@ -1502,14 +1597,72 @@ textarea{
|
|
|
1502
1597
|
}
|
|
1503
1598
|
textarea:focus{outline:none}
|
|
1504
1599
|
textarea::placeholder{color:var(--dim)}
|
|
1505
|
-
|
|
1506
|
-
|
|
1600
|
+
.input-wrap{position:relative}
|
|
1601
|
+
/* Block, not the inline-block a textarea is by default: inline leaves room
|
|
1602
|
+
under the baseline for a descender, so the wrapper ends up a few pixels
|
|
1603
|
+
taller than the field inside it and the two scroll by different amounts. */
|
|
1604
|
+
/* Above the backdrop, and that needs saying: the mirror is absolutely
|
|
1605
|
+
positioned, which puts it in front of a static sibling however the two are
|
|
1606
|
+
ordered in the markup. Without this the chip paints over the words it is
|
|
1607
|
+
drawn for. */
|
|
1608
|
+
.input-wrap textarea{display:block;position:relative;z-index:1}
|
|
1609
|
+
/* The field draws the caret and the selection; the mirror draws the words.
|
|
1610
|
+
With the glyphs in the mirror a mention can carry something a textarea
|
|
1611
|
+
never could — an icon in the slot the @ sign would have taken — because
|
|
1612
|
+
the field only has to agree on where each glyph lands, not on what is
|
|
1613
|
+
drawn there. The selection is translucent so the words under it stay
|
|
1614
|
+
readable; an opaque highlight would sit on top of the mirror. */
|
|
1615
|
+
.input-wrap textarea{color:transparent;caret-color:var(--fg)}
|
|
1616
|
+
.input-wrap textarea::selection{background:#3b82f640}
|
|
1617
|
+
/* Every metric that decides where a glyph lands has to match the field: font,
|
|
1618
|
+
line-height, wrapping, and the padding, which is zero on this textarea.
|
|
1619
|
+
Six pixels wider on each side with the text pushed back in by six: the
|
|
1620
|
+
glyphs land where the textarea puts them, and the chip's background — drawn
|
|
1621
|
+
outside its own advance width — has somewhere to go instead of being cut off
|
|
1622
|
+
at the start of a line. */
|
|
1623
|
+
#input-mirror{
|
|
1624
|
+
position:absolute;top:0;bottom:0;left:-6px;right:-6px;padding:0 6px;border:0;
|
|
1625
|
+
font:inherit;line-height:1.6;white-space:pre-wrap;overflow-wrap:break-word;
|
|
1626
|
+
color:var(--fg);pointer-events:none;overflow:hidden;
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* The floating chip's surface, inline. No remove button — Backspace takes the
|
|
1630
|
+
* whole mention instead — and the icon lives in the slot the @ sign vacated.
|
|
1631
|
+
*
|
|
1632
|
+
* White on the field, dark on the dark field — the same two tokens the chip
|
|
1633
|
+
* above the box uses. Vertical padding does not enter the inline advance and
|
|
1634
|
+
* the line-height leaves room for it. Horizontal padding is given back as
|
|
1635
|
+
* negative margin so no glyph moves: the wash reaches into the whitespace on
|
|
1636
|
+
* either side of the mention. A space is four pixels wide, and the menu
|
|
1637
|
+
* inserts two of them on each side (see roomBefore), which is what allows
|
|
1638
|
+
* four of padding and still four of clearance to the next word.
|
|
1639
|
+
*
|
|
1640
|
+
* The shadow is a lift, not an outline. A hard ring made each mention read as
|
|
1641
|
+
* a small input field dropped into the middle of a sentence.
|
|
1642
|
+
*/
|
|
1643
|
+
#input-mirror mark{
|
|
1644
|
+
background:var(--bg);color:var(--fg);border-radius:6px;
|
|
1645
|
+
padding:3px 4px;margin:0 -4px;
|
|
1646
|
+
box-shadow:var(--mention-lift);
|
|
1647
|
+
box-decoration-break:clone;-webkit-box-decoration-break:clone;
|
|
1648
|
+
}
|
|
1649
|
+
/* The @ sign's own advance width, measured on every paint, with the icon of
|
|
1650
|
+
what was mentioned drawn in it instead. The width is the field's, so the
|
|
1651
|
+
name after it lands exactly where the field puts it. */
|
|
1652
|
+
#input-mirror mark .at{
|
|
1653
|
+
display:inline-block;width:var(--at-w,13.5px);vertical-align:-1.5px;
|
|
1654
|
+
color:var(--dim);
|
|
1655
|
+
}
|
|
1656
|
+
#input-mirror mark .at svg{width:11px;height:11px;display:block;margin-left:.5px}
|
|
1657
|
+
/* The mirror sits exactly under the textarea, so every metric that decides
|
|
1658
|
+
where a glyph lands has to match: font, line-height, wrapping, and the
|
|
1659
|
+
padding, which is zero on the composer's textarea and must be zero here. */
|
|
1507
1660
|
textarea.boxed{
|
|
1508
1661
|
border:1px solid var(--line);background:var(--field);border-radius:6px;
|
|
1509
1662
|
padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
|
|
1510
1663
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
1511
1664
|
}
|
|
1512
|
-
textarea.boxed:focus{
|
|
1665
|
+
textarea.boxed:focus{outline:none;background:var(--field-focus)}
|
|
1513
1666
|
.composer-bar{display:flex;align-items:center;gap:6px;margin-top:10px}
|
|
1514
1667
|
.composer.drag{background:var(--field-focus);outline:2px dashed var(--line);outline-offset:-6px}
|
|
1515
1668
|
.icon-btn{
|
|
@@ -1540,8 +1693,32 @@ textarea.boxed:focus{border-color:var(--dim)}
|
|
|
1540
1693
|
/* Wider than the model picker's: these rows carry a name and the folder it
|
|
1541
1694
|
sits in, and truncating the folder away leaves three files called index.ts. */
|
|
1542
1695
|
#attach-picker .menu{min-width:min(380px,86vw)}
|
|
1543
|
-
#attach-picker .menu-item{cursor:pointer}
|
|
1696
|
+
#attach-picker .menu-item{cursor:pointer;gap:9px;padding-left:9px;padding-right:9px}
|
|
1697
|
+
#attach-picker .menu-item .tick{width:auto;opacity:.55;margin-left:auto;padding-left:8px}
|
|
1698
|
+
#attach-picker .menu-item.on .tick{opacity:.85}
|
|
1699
|
+
/* A step up from hover, not a slab of ink. The keyboard cursor and the pointer
|
|
1700
|
+
should not look identical — one of them means Enter takes this row — but the
|
|
1701
|
+
menu is a quiet surface and the difference is worth one tone, not two. */
|
|
1702
|
+
#attach-picker .menu-item.cursor{background:var(--sel)}
|
|
1703
|
+
#attach-picker .menu-item.cursor .ico-sm{opacity:.8}
|
|
1544
1704
|
#attach-picker .menu-item .ico-sm{opacity:.55}
|
|
1705
|
+
/* The stacked rows carry a name over a sentence, so they are already two lines
|
|
1706
|
+
tall; the mark beside them is a label for the kind, not an illustration, and
|
|
1707
|
+
at 16px it was the widest thing in a row whose job is the words. */
|
|
1708
|
+
/* Centred on the name, not on the row. A stacked row is a name over a
|
|
1709
|
+
sentence, so its box centre sits somewhere in the gap between the two lines
|
|
1710
|
+
and a mark aligned to it reads as sagging. The name's line box is 22px at
|
|
1711
|
+
this size and the mark is 13px, so half the difference puts one centre on
|
|
1712
|
+
the other. The rule above this one sets the 1px the flush-left tick needed
|
|
1713
|
+
when every row was a single line; on these it is the same 1px of sag. */
|
|
1714
|
+
#attach-picker .menu-item.stacked .ico-sm{width:13px;height:13px;margin-top:4.5px}
|
|
1715
|
+
/* The vendor mark at the row's glyph size, the mono tile lettered to match. */
|
|
1716
|
+
#attach-picker .menu-item .mk-logo,#attach-picker .menu-item .mk-mono{
|
|
1717
|
+
width:16px;height:16px;border-radius:4px;margin-top:3px;font-size:9px;
|
|
1718
|
+
}
|
|
1719
|
+
#attach-picker .menu-item.stacked .tick{margin-top:0}
|
|
1720
|
+
#attach-picker .menu-item.stacked{padding-top:5px;padding-bottom:5px}
|
|
1721
|
+
#attach-picker .menu-item.stacked .stack{gap:1px}
|
|
1545
1722
|
#attach-picker .menu-item.on .ico-sm{opacity:.9}
|
|
1546
1723
|
.menu-hint{padding:6px 8px 8px;font-size:11.5px;color:var(--dim)}
|
|
1547
1724
|
/* A row that has something to say about itself: the name on one line and a
|
|
@@ -1567,6 +1744,13 @@ textarea.boxed:focus{border-color:var(--dim)}
|
|
|
1567
1744
|
the arrow keys. A ring around a popup that was just clicked open says
|
|
1568
1745
|
nothing the row highlight is not already saying. */
|
|
1569
1746
|
.menu:focus{outline:none}
|
|
1747
|
+
/* No ring and no border marks a focused or selected thing anywhere in the app.
|
|
1748
|
+
That includes the browser's own focus outline, which drew a blue ring round
|
|
1749
|
+
the account button while its menu was open. Keyboard focus is carried by a
|
|
1750
|
+
fill instead; the zero-specificity selector leaves every control that
|
|
1751
|
+
already paints its own background alone. */
|
|
1752
|
+
:focus,:focus-visible{outline:none}
|
|
1753
|
+
:where(button,a,summary,[role="button"],[tabindex]):focus-visible{background-color:var(--hover)}
|
|
1570
1754
|
/* The triangle the group headers use, turned on a row that leads somewhere:
|
|
1571
1755
|
the + opens onto four offers, and three of them go one screen deeper. */
|
|
1572
1756
|
.menu-item .chev{
|
|
@@ -1678,14 +1862,134 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
|
|
|
1678
1862
|
padding:8px 18px 16px;font-size:11.5px;line-height:1.6;color:var(--dim);min-height:34px;
|
|
1679
1863
|
}
|
|
1680
1864
|
|
|
1865
|
+
/*
|
|
1866
|
+
* Phone-only chrome, declared before the breakpoint that shows it: a base
|
|
1867
|
+
* rule written after the media query wins the cascade, which is how the
|
|
1868
|
+
* burger spent a release at display:none on every width.
|
|
1869
|
+
*/
|
|
1870
|
+
#burger{
|
|
1871
|
+
display:none;width:36px;height:36px;margin-left:-8px;border:0;border-radius:8px;
|
|
1872
|
+
background:none;color:var(--fg);font:inherit;font-size:18px;cursor:pointer;place-items:center;
|
|
1873
|
+
}
|
|
1874
|
+
#burger:hover{background:var(--hover)}
|
|
1875
|
+
#nav-close{
|
|
1876
|
+
display:none;width:32px;height:32px;margin-left:auto;border:0;border-radius:8px;
|
|
1877
|
+
background:transparent;color:var(--dim);cursor:pointer;padding:0;place-items:center;
|
|
1878
|
+
}
|
|
1879
|
+
#nav-close svg{width:14px;height:14px}
|
|
1880
|
+
#nav-close:hover{background:var(--hover);color:var(--fg)}
|
|
1881
|
+
#scrim{display:none;position:fixed;inset:0;z-index:19;background:#00000059}
|
|
1882
|
+
#review-close{
|
|
1883
|
+
display:none;width:32px;height:32px;border:0;border-radius:8px;background:transparent;
|
|
1884
|
+
color:var(--fg);cursor:pointer;padding:0;place-items:center;margin-left:8px;
|
|
1885
|
+
}
|
|
1886
|
+
#review-close svg{width:16px;height:16px}
|
|
1887
|
+
|
|
1888
|
+
/*
|
|
1889
|
+
* A phone.
|
|
1890
|
+
*
|
|
1891
|
+
* The desktop layout is three columns that share one window; a phone has room
|
|
1892
|
+
* for one of them at a time. The sidebar becomes a drawer over the transcript,
|
|
1893
|
+
* the review and artifact panes take the whole screen while they are open,
|
|
1894
|
+
* and the composer's bar is split in two: where the session runs and what it
|
|
1895
|
+
* may do sit above the text, the model and the send button below it.
|
|
1896
|
+
*/
|
|
1681
1897
|
@media (max-width:760px){
|
|
1682
|
-
aside{
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1898
|
+
body>aside{
|
|
1899
|
+
position:fixed;inset:0 auto 0 0;z-index:20;width:min(280px,84vw);flex-basis:auto;
|
|
1900
|
+
transform:translateX(-100%);transition:transform .2s;
|
|
1901
|
+
padding-bottom:env(safe-area-inset-bottom);
|
|
1902
|
+
}
|
|
1903
|
+
body>aside.open{transform:none;box-shadow:0 0 0 1px #0000000a,0 12px 40px #00000040}
|
|
1904
|
+
body.nav-open #scrim{display:block}
|
|
1905
|
+
#nav-close{display:grid}
|
|
1906
|
+
#burger{display:grid}
|
|
1907
|
+
#top{padding:6px 10px}
|
|
1908
|
+
#top:has(#changes-btn:not([hidden])){padding:6px 10px}
|
|
1909
|
+
|
|
1910
|
+
/* Both panes are the screen while open, and each gets a way out of its own:
|
|
1911
|
+
the top bar with the changes button is underneath them. */
|
|
1912
|
+
#review.open,#shelf.open{
|
|
1913
|
+
position:fixed;inset:0;z-index:50;width:100%;height:100%;flex:none;transition:none;
|
|
1914
|
+
}
|
|
1915
|
+
#review-head{padding:10px 10px 8px 16px}
|
|
1916
|
+
#review-close{display:grid}
|
|
1917
|
+
#files{max-height:40%}
|
|
1918
|
+
#shelf-bar{flex-wrap:wrap;row-gap:4px}
|
|
1919
|
+
|
|
1920
|
+
#log{padding:16px 14px 8px}
|
|
1921
|
+
#panel-view{padding:20px 14px 24px}
|
|
1922
|
+
.empty-brand{gap:12px;margin-bottom:22px}
|
|
1923
|
+
.brand-mark,.brand-mark svg{width:48px;height:48px}
|
|
1924
|
+
.brand-name{font-size:30px}
|
|
1925
|
+
#empty{margin-top:4vh}
|
|
1926
|
+
|
|
1927
|
+
/* The composer. The bar's children become items of the box itself so they
|
|
1928
|
+
can be dealt into two rows around the field; the bar element stays in the
|
|
1929
|
+
markup for the desktop layout and the scripts that hang menus off it. */
|
|
1930
|
+
form{padding:6px 12px calc(12px + env(safe-area-inset-bottom))}
|
|
1931
|
+
.composer{
|
|
1932
|
+
position:relative;display:flex;flex-wrap:wrap;align-items:center;
|
|
1933
|
+
column-gap:6px;row-gap:8px;padding:10px 12px 10px;
|
|
1934
|
+
}
|
|
1935
|
+
.composer-bar{display:contents}
|
|
1936
|
+
#attachments{order:-3;flex:0 0 100%;margin-bottom:0}
|
|
1937
|
+
#workspace-picker,#mode-picker{order:-2}
|
|
1938
|
+
.input-wrap{order:-1;flex:0 0 100%}
|
|
1939
|
+
.composer .picker{position:static;min-width:0}
|
|
1940
|
+
.composer .picker-btn{max-width:100%;height:32px}
|
|
1941
|
+
#send,#stop{flex:0 0 auto}
|
|
1942
|
+
textarea{max-height:32dvh}
|
|
1943
|
+
/* Menus span the box rather than hang off one picker: from a picker on the
|
|
1944
|
+
right they otherwise open past the edge, where overflow:hidden loses them. */
|
|
1945
|
+
.composer .menu,#attach-picker .menu{
|
|
1946
|
+
left:0;right:0;width:auto;min-width:0;max-width:none;bottom:calc(100% + 8px);
|
|
1947
|
+
}
|
|
1948
|
+
.menu-body{max-height:min(50dvh,340px)}
|
|
1949
|
+
#jump{right:14px;bottom:160px}
|
|
1950
|
+
|
|
1951
|
+
/* Dialogs, with the keyboard up: vh is the height without it. */
|
|
1952
|
+
#market{max-height:82dvh}
|
|
1953
|
+
#provider-picker,#browse{max-height:76dvh}
|
|
1954
|
+
#project-picker{max-height:70dvh}
|
|
1955
|
+
.mk-grid{grid-template-columns:minmax(0,1fr)}
|
|
1956
|
+
.mk-search{padding-right:44px}
|
|
1957
|
+
.ask-body{max-height:min(260px,40dvh)}
|
|
1958
|
+
.ask-line .n{flex:0 0 28px;padding-right:6px}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/*
|
|
1962
|
+
* A finger, not a pointer.
|
|
1963
|
+
*
|
|
1964
|
+
* Hover reveals nothing on a touch screen, so what the desktop shows on hover
|
|
1965
|
+
* is shown outright; and a field under 16px makes iOS zoom the page when it
|
|
1966
|
+
* is focused, so every field is 16px here whatever it is elsewhere.
|
|
1967
|
+
*/
|
|
1968
|
+
@media (hover:none) and (pointer:coarse){
|
|
1969
|
+
.session .more,.fold-row .more{display:grid;width:28px;height:28px}
|
|
1970
|
+
.proj-forget{opacity:1}
|
|
1971
|
+
.actions{position:static;opacity:1;margin-top:4px}
|
|
1972
|
+
.turn{margin-bottom:14px}
|
|
1973
|
+
.act{padding:6px 10px}
|
|
1974
|
+
.tab .x{opacity:.55;width:24px;height:24px}
|
|
1975
|
+
#tab-add{width:32px;height:32px}
|
|
1976
|
+
.tab{height:34px}
|
|
1977
|
+
.nav-item,.session,.fold,.project{height:38px}
|
|
1978
|
+
.show-more{height:32px}
|
|
1979
|
+
.icon-btn{width:34px;height:34px}
|
|
1980
|
+
button.primary{width:36px;height:36px}
|
|
1981
|
+
button.ghost,a.ghost{height:36px}
|
|
1982
|
+
.att .x{padding:4px 6px}
|
|
1983
|
+
.cn-x,.mk-close,.theme-btn{width:36px;height:36px}
|
|
1984
|
+
.cn-why{width:28px;height:28px}
|
|
1985
|
+
.switch{width:40px;height:24px}
|
|
1986
|
+
.dlg-actions button{padding:10px 14px}
|
|
1987
|
+
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),select,textarea,
|
|
1988
|
+
.menu-search input,.mk-search,.art-search,.keyrow input,.cn-body input,.pp-head input,
|
|
1989
|
+
textarea.boxed{font-size:16px}
|
|
1990
|
+
/* The chip layer under the field has to keep the field's metrics. */
|
|
1991
|
+
#input-mirror{font-size:16px}
|
|
1687
1992
|
}
|
|
1688
|
-
#burger{display:none;background:none;border:0;color:var(--fg);font:inherit;cursor:pointer;font-size:18px}
|
|
1689
1993
|
`;
|
|
1690
1994
|
const APP_SCRIPT = String.raw `
|
|
1691
1995
|
const STRINGS = window.__STRINGS;
|
|
@@ -1711,6 +2015,10 @@ if (window.oharness && window.oharness.insetTitlebar) {
|
|
|
1711
2015
|
/** Whether this page is the desktop window rather than a browser tab. */
|
|
1712
2016
|
const IS_DESKTOP = Boolean(window.oharness && window.oharness.desktop);
|
|
1713
2017
|
|
|
2018
|
+
/** Whether the page is laid out for a phone right now; the stylesheet's breakpoint. */
|
|
2019
|
+
const PHONE_QUERY = window.matchMedia("(max-width:760px)");
|
|
2020
|
+
const isPhone = () => PHONE_QUERY.matches;
|
|
2021
|
+
|
|
1714
2022
|
// --- language --------------------------------------------------------------
|
|
1715
2023
|
|
|
1716
2024
|
/**
|
|
@@ -1769,6 +2077,21 @@ const ICON_PATHS = {
|
|
|
1769
2077
|
// Stacked plates, because a plugin arrives as a bundle of contributions
|
|
1770
2078
|
// rather than as one thing.
|
|
1771
2079
|
plugin: ["M8 1.33 1.33 4.67 8 8l6.67-3.33Z", "M1.33 11.33 8 14.67l6.67-3.34", "M1.33 8 8 11.33 14.67 8"],
|
|
2080
|
+
/* A bookmark: one path, and the only shape in this menu that is neither a
|
|
2081
|
+
sheet nor a folder. The open book it replaced was two mirrored paths
|
|
2082
|
+
meeting at a spine, which at the 13px these rows use closed into a blob —
|
|
2083
|
+
two strokes a pixel apart read as one thick one. */
|
|
2084
|
+
skill: ["M4.5 2.5h7v11l-3.5-2.6-3.5 2.6Z"],
|
|
2085
|
+
/* A prompt caret: what a command line looks like anywhere else. */
|
|
2086
|
+
command: ["M4 5.5 6.5 8 4 10.5", "M8.5 10.5H12"],
|
|
2087
|
+
/* A bare chevron. The other back arrow has a shaft, which is right on a
|
|
2088
|
+
page header and heavy on a menu row sitting one line above the thing it
|
|
2089
|
+
returns to. */
|
|
2090
|
+
chevronLeft: ["M9.5 4.5 6 8l3.5 3.5"],
|
|
2091
|
+
/* Two strokes, for the corner of a dialog. */
|
|
2092
|
+
close: ["M4.5 4.5l7 7", "M11.5 4.5l-7 7"],
|
|
2093
|
+
search: ["M11.5 11.5 14 14"],
|
|
2094
|
+
searchCircles: [[7.3, 7.3, 4.6]],
|
|
1772
2095
|
// A shopfront: an awning over a counter, for the list of what is on offer.
|
|
1773
2096
|
market: ["M2 6.67h12", "M3.33 6.67 4.67 2.67h6.66l1.34 4", "M2.67 6.67v6.66h10.67V6.67"],
|
|
1774
2097
|
// An arrow pointing back the way the menu came in.
|
|
@@ -1802,6 +2125,31 @@ const ICON_PATHS = {
|
|
|
1802
2125
|
"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" +
|
|
1803
2126
|
"A1.33 1.33 0 0 1 14 3.33Z",
|
|
1804
2127
|
],
|
|
2128
|
+
// The five permission modes. Five short words in a column look alike until
|
|
2129
|
+
// they are read; a glyph in front of each lets the one in use be found by
|
|
2130
|
+
// shape from the bar, the way the vendor mark does for the model.
|
|
2131
|
+
//
|
|
2132
|
+
// Manual is by hand, and a raised palm is also the sign for "wait": three
|
|
2133
|
+
// fingers, a thumb, and the heel of the hand in one sweep. Four fingers
|
|
2134
|
+
// would be the honest count, but at fifteen pixels the fourth turns the
|
|
2135
|
+
// top edge into a comb.
|
|
2136
|
+
modeAsk: [
|
|
2137
|
+
"M12 7.4V4a1.33 1.33 0 0 0-2.67 0",
|
|
2138
|
+
"M9.33 6.7V2.7a1.33 1.33 0 0 0-2.66 0v1.3",
|
|
2139
|
+
"M6.67 7V4A1.33 1.33 0 0 0 4 4v5.33",
|
|
2140
|
+
"M12 5.33a1.33 1.33 0 1 1 2.67 0v4a5.33 5.33 0 0 1-5.34 5.34H8" +
|
|
2141
|
+
"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",
|
|
2142
|
+
],
|
|
2143
|
+
// Accept edits lets the pencil through.
|
|
2144
|
+
modeEdits: ["M11.3 2.7 13.3 4.7 5.3 12.7H3.3v-2Z", "M9.8 4.2l2 2"],
|
|
2145
|
+
// Plan is read-only: an eye, which looks and does not touch.
|
|
2146
|
+
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"],
|
|
2147
|
+
modePlanCircles: [[8, 8, 2.1]],
|
|
2148
|
+
// Auto waves the routine through: a bolt.
|
|
2149
|
+
modeAuto: ["M8.8 1.6 3.6 9h4l-.7 5.4L12.4 7h-4Z"],
|
|
2150
|
+
// Bypass leaves the gate open: a padlock with its shackle swung clear.
|
|
2151
|
+
modeBypass: ["M5.4 7.4V5.2a2.6 2.6 0 0 1 5-1"],
|
|
2152
|
+
modeBypassRects: [[3.2, 7.4, 9.6, 6.2, 1.6]],
|
|
1805
2153
|
};
|
|
1806
2154
|
|
|
1807
2155
|
function svgIcon(name, extra) {
|
|
@@ -2081,6 +2429,27 @@ function providerMark(id, label, extra) {
|
|
|
2081
2429
|
return mono;
|
|
2082
2430
|
}
|
|
2083
2431
|
|
|
2432
|
+
/**
|
|
2433
|
+
* The model id, shortened to what the bar has room for: everything after the
|
|
2434
|
+
* last slash.
|
|
2435
|
+
*
|
|
2436
|
+
* The rule used to be a length test — over 28 characters keep the tail, under
|
|
2437
|
+
* it keep the whole thing — which is how "~deepseek/deepseek-v4-flash" reached
|
|
2438
|
+
* the bar at 27 characters, naming its vendor twice and then being cut
|
|
2439
|
+
* mid-word by the ellipsis anyway. Length was the wrong question: what to drop
|
|
2440
|
+
* does not depend on how long the string happens to be.
|
|
2441
|
+
*
|
|
2442
|
+
* What to drop is the namespace, every time. The vendor's mark is drawn
|
|
2443
|
+
* immediately to the left of this, so the prefix is a picture repeated in
|
|
2444
|
+
* words — and an aggregator's id nests, which makes the prefix the longer half
|
|
2445
|
+
* of a name whose distinguishing half is at the end. The whole id stays in the
|
|
2446
|
+
* title attribute and in the menu.
|
|
2447
|
+
*/
|
|
2448
|
+
function shortModel(full) {
|
|
2449
|
+
const cut = full.lastIndexOf("/");
|
|
2450
|
+
return cut < 0 ? full : full.slice(cut + 1);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2084
2453
|
function makePicker(id, onPick, extra) {
|
|
2085
2454
|
const root = document.getElementById(id);
|
|
2086
2455
|
const button = root.querySelector(".picker-btn");
|
|
@@ -2294,10 +2663,8 @@ function makePicker(id, onPick, extra) {
|
|
|
2294
2663
|
if (stale) stale.remove();
|
|
2295
2664
|
const mark = match && match.mark && match.mark("picker-mark");
|
|
2296
2665
|
if (mark) button.insertBefore(mark, label);
|
|
2297
|
-
// Discovered ids are namespaced and long; the tail is the part that
|
|
2298
|
-
// identifies the model, and the full id is still in the menu.
|
|
2299
2666
|
const full = match ? match.label : current || "—";
|
|
2300
|
-
label.textContent =
|
|
2667
|
+
label.textContent = shortModel(full);
|
|
2301
2668
|
label.title = full;
|
|
2302
2669
|
},
|
|
2303
2670
|
get value() {
|
|
@@ -2307,12 +2674,16 @@ function makePicker(id, onPick, extra) {
|
|
|
2307
2674
|
return api;
|
|
2308
2675
|
}
|
|
2309
2676
|
|
|
2677
|
+
// Drawn glyphs rather than vendor tiles: a mode is chrome, not a party being
|
|
2678
|
+
// paid, so it gets the bare stroke every other menu icon has.
|
|
2679
|
+
const modeMark = (icon) => (extra) => svgIcon(icon, "mode-mark" + (extra ? " " + extra : ""));
|
|
2680
|
+
|
|
2310
2681
|
const MODES = [
|
|
2311
|
-
{ value: "default", label: "Manual", sub: "ask first" },
|
|
2312
|
-
{ value: "acceptEdits", label: "Accept edits", sub: "edits pass" },
|
|
2313
|
-
{ value: "plan", label: "Plan", sub: "read-only" },
|
|
2314
|
-
{ value: "auto", label: "Auto", sub: "routine passes" },
|
|
2315
|
-
{ value: "bypassPermissions", label: "Bypass", sub: "all pass" },
|
|
2682
|
+
{ value: "default", label: "Manual", sub: "ask first", mark: modeMark("modeAsk") },
|
|
2683
|
+
{ value: "acceptEdits", label: "Accept edits", sub: "edits pass", mark: modeMark("modeEdits") },
|
|
2684
|
+
{ value: "plan", label: "Plan", sub: "read-only", mark: modeMark("modePlan") },
|
|
2685
|
+
{ value: "auto", label: "Auto", sub: "routine passes", mark: modeMark("modeAuto") },
|
|
2686
|
+
{ value: "bypassPermissions", label: "Bypass", sub: "all pass", mark: modeMark("modeBypass") },
|
|
2316
2687
|
];
|
|
2317
2688
|
|
|
2318
2689
|
const modelPicker = makePicker(
|
|
@@ -3332,6 +3703,8 @@ function closeRowMenu() {
|
|
|
3332
3703
|
menuRow = null;
|
|
3333
3704
|
rowMenu.hidden = true;
|
|
3334
3705
|
rowMenu.replaceChildren();
|
|
3706
|
+
rowMenu.classList.remove("fit-anchor", "account-menu");
|
|
3707
|
+
rowMenu.style.width = "";
|
|
3335
3708
|
}
|
|
3336
3709
|
|
|
3337
3710
|
/**
|
|
@@ -3344,8 +3717,9 @@ function placeRowMenu(button) {
|
|
|
3344
3717
|
const anchor = button.getBoundingClientRect();
|
|
3345
3718
|
const box = rowMenu.getBoundingClientRect();
|
|
3346
3719
|
const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
|
|
3347
|
-
const
|
|
3348
|
-
const
|
|
3720
|
+
const gap = rowMenu.classList.contains("account-menu") ? 8 : 4;
|
|
3721
|
+
const below = anchor.bottom + gap;
|
|
3722
|
+
const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - gap : below;
|
|
3349
3723
|
rowMenu.style.left = Math.max(8, left) + "px";
|
|
3350
3724
|
rowMenu.style.top = Math.max(8, top) + "px";
|
|
3351
3725
|
}
|
|
@@ -4015,7 +4389,9 @@ events.onmessage = (message) => {
|
|
|
4015
4389
|
if (wasSelected) void showArtifact(event.artifact.id);
|
|
4016
4390
|
// First artifact of the session opens the drawer, the way the first file
|
|
4017
4391
|
// change makes the review button appear. Later ones do not steal focus.
|
|
4018
|
-
|
|
4392
|
+
// Not on a phone, where the drawer is the whole screen: it would cover
|
|
4393
|
+
// the reply that produced the artifact.
|
|
4394
|
+
if (artifacts.length === 1 && !isPhone()) shelf.classList.add("open");
|
|
4019
4395
|
break;
|
|
4020
4396
|
}
|
|
4021
4397
|
|
|
@@ -4074,7 +4450,7 @@ events.onmessage = (message) => {
|
|
|
4074
4450
|
case "state": applyState(event.state); break;
|
|
4075
4451
|
case "connectors_changed":
|
|
4076
4452
|
if (authorizing === event.connector) authorizing = null;
|
|
4077
|
-
offer = { connectors: event.connectors, plugins: event.plugins || [] };
|
|
4453
|
+
offer = { connectors: event.connectors, plugins: event.plugins || [], skills: event.skills || [] };
|
|
4078
4454
|
// The failure already said what went wrong; this only announces the
|
|
4079
4455
|
// half that is worth celebrating.
|
|
4080
4456
|
if (event.authorized) addNotice(t("market.authorized", { name: event.connector }));
|
|
@@ -4369,7 +4745,7 @@ const sentPreviews = new Map();
|
|
|
4369
4745
|
* server turns the list into a short block above the prompt.
|
|
4370
4746
|
*/
|
|
4371
4747
|
let refs = [];
|
|
4372
|
-
const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin" };
|
|
4748
|
+
const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
|
|
4373
4749
|
const refKey = (ref) => ref.kind + ":" + ref.value;
|
|
4374
4750
|
|
|
4375
4751
|
function renderAttachments() {
|
|
@@ -4498,8 +4874,10 @@ attachMenu.replaceChildren(attachBody);
|
|
|
4498
4874
|
let attachView = "root";
|
|
4499
4875
|
/** The last answer from /files, for the mention list. */
|
|
4500
4876
|
let fileList = { entries: [] };
|
|
4877
|
+
/** Whether the context endpoint has answered once, for the mention list. */
|
|
4878
|
+
let offerLoaded = false;
|
|
4501
4879
|
/** Connectors and plugins, refreshed each time the menu opens. */
|
|
4502
|
-
let offer = { connectors: [], plugins: [] };
|
|
4880
|
+
let offer = { connectors: [], plugins: [], skills: [] };
|
|
4503
4881
|
/** The connectors on offer rather than the ones already here. */
|
|
4504
4882
|
let market = [];
|
|
4505
4883
|
/** The connector whose sign-in is open in a browser tab, if any. */
|
|
@@ -4536,18 +4914,175 @@ function addRef(kind, value) {
|
|
|
4536
4914
|
|
|
4537
4915
|
/** The rows on screen, in the order they are painted — what a key can land on. */
|
|
4538
4916
|
function attachRows() {
|
|
4917
|
+
/**
|
|
4918
|
+
* What this project can reach, three or four deep, over the full catalogue.
|
|
4919
|
+
*
|
|
4920
|
+
* The menu used to hand every one of these straight to a dialog, which is
|
|
4921
|
+
* the right home for eighty skills and the wrong one for the two somebody
|
|
4922
|
+
* actually attaches. So the menu answers the common question itself — what
|
|
4923
|
+
* is here, attach one — and the dialog keeps the uncommon ones: read all of
|
|
4924
|
+
* them, install something new, take something off.
|
|
4925
|
+
*/
|
|
4926
|
+
if (attachView === "skills" || attachView === "plugins" || attachView === "connectors") {
|
|
4927
|
+
const kind = attachView;
|
|
4928
|
+
const items =
|
|
4929
|
+
kind === "skills" ? offer.skills || []
|
|
4930
|
+
: kind === "plugins" ? offer.plugins || []
|
|
4931
|
+
: offer.connectors || [];
|
|
4932
|
+
const row = (entry) => ({
|
|
4933
|
+
type: "offer-ref",
|
|
4934
|
+
icon: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "plug",
|
|
4935
|
+
// A connector is known by its vendor's mark, the same one the dialog
|
|
4936
|
+
// draws; a plug in front of three names is three rows told apart by
|
|
4937
|
+
// reading.
|
|
4938
|
+
mark: kind === "connectors" ? entry.name : undefined,
|
|
4939
|
+
label: entry.title || entry.name,
|
|
4940
|
+
note: entry.description || "",
|
|
4941
|
+
ref: {
|
|
4942
|
+
kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
|
|
4943
|
+
value: entry.name,
|
|
4944
|
+
label: entry.name,
|
|
4945
|
+
},
|
|
4946
|
+
});
|
|
4947
|
+
/**
|
|
4948
|
+
* One list, with the project's own first.
|
|
4949
|
+
*
|
|
4950
|
+
* A skill checked into the repository is likelier to be the one wanted
|
|
4951
|
+
* here, so it is not left to sort alphabetically among eighty that happen
|
|
4952
|
+
* to be installed. That is an ordering, not a division: both are usable in
|
|
4953
|
+
* this session and a heading between them would be drawing a line where
|
|
4954
|
+
* the user does not have to act on one.
|
|
4955
|
+
*/
|
|
4956
|
+
const local = kind === "connectors" ? [] : items.filter((entry) => entry.source === "project");
|
|
4957
|
+
const shown = [...local, ...items.filter((entry) => !local.includes(entry))].slice(0, 6);
|
|
4958
|
+
const rows = shown.map(row);
|
|
4959
|
+
const empty = { skills: "add.noSkills", plugins: "add.noPlugins", connectors: "add.noConnectors" };
|
|
4960
|
+
const hidden = items.length - shown.length;
|
|
4961
|
+
return [
|
|
4962
|
+
{ type: "up", icon: "chevronLeft", label: t("add.back") },
|
|
4963
|
+
...rows,
|
|
4964
|
+
...(!items.length ? [{ type: "hint", label: t(empty[kind]) }] : []),
|
|
4965
|
+
...(hidden > 0 ? [{ type: "hint", label: t("add.andMore", { n: hidden }) }] : []),
|
|
4966
|
+
// Browse is only a different question where there is somewhere to browse:
|
|
4967
|
+
// connectors have a registry of ones not installed, skills and plugins
|
|
4968
|
+
// are whatever is on this machine already. "More" only when the menu
|
|
4969
|
+
// is holding some back; with everything in view it is the catalogue.
|
|
4970
|
+
...(kind === "connectors"
|
|
4971
|
+
? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll") }]
|
|
4972
|
+
: []),
|
|
4973
|
+
{ type: "manage", kind, icon: "settings", label: t("add.manage") },
|
|
4974
|
+
];
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
if (attachView === "command") {
|
|
4978
|
+
return COMMANDS.filter((one) => !attachQuery || one.name.startsWith(attachQuery)).map((one) => ({
|
|
4979
|
+
type: "command",
|
|
4980
|
+
icon: "command",
|
|
4981
|
+
label: "/" + one.name + (one.arg ? " <" + one.arg + ">" : ""),
|
|
4982
|
+
note: one.hint(),
|
|
4983
|
+
command: one.name,
|
|
4984
|
+
}));
|
|
4985
|
+
}
|
|
4539
4986
|
if (attachView === "mention") {
|
|
4540
|
-
|
|
4987
|
+
/**
|
|
4988
|
+
* Skills first, then files.
|
|
4989
|
+
*
|
|
4990
|
+
* The @ began as a way to name a file and files are still most of what it
|
|
4991
|
+
* names, but a skill is the other thing worth pointing at from inside a
|
|
4992
|
+
* sentence — and the only one where naming it does something: the model is
|
|
4993
|
+
* told which skills exist, so naming one asks for it directly instead of
|
|
4994
|
+
* leaving the choice to whether the task happened to match a description.
|
|
4995
|
+
* There are a handful of them against thousands of files, so they go above
|
|
4996
|
+
* rather than competing on rank.
|
|
4997
|
+
*/
|
|
4998
|
+
const query = attachQuery.toLowerCase();
|
|
4999
|
+
/**
|
|
5000
|
+
* A skill matches on what it is for, not only on what it is called.
|
|
5001
|
+
*
|
|
5002
|
+
* Names are terse and chosen by whoever wrote the skill; the sentence
|
|
5003
|
+
* under the name is the one a person would think of. Someone after the
|
|
5004
|
+
* video tools types "video", and only the description contains it.
|
|
5005
|
+
*/
|
|
5006
|
+
const skills = (offer.skills || [])
|
|
5007
|
+
.filter((skill) =>
|
|
5008
|
+
!query ||
|
|
5009
|
+
skill.name.toLowerCase().includes(query) ||
|
|
5010
|
+
(skill.description || "").toLowerCase().includes(query),
|
|
5011
|
+
)
|
|
5012
|
+
/**
|
|
5013
|
+
* A few, not all of them.
|
|
5014
|
+
*
|
|
5015
|
+
* This list is mostly files and should stay that way: eighty skills
|
|
5016
|
+
* answering a one-letter query push every file below a fold nobody
|
|
5017
|
+
* scrolls, and @ was a way to name a file first. Four, because a skill
|
|
5018
|
+
* row is two lines — six of them filled the menu exactly and the files
|
|
5019
|
+
* began again just past its bottom edge, which is the same problem with
|
|
5020
|
+
* a smaller number on it. The query narrows them as fast as anyone
|
|
5021
|
+
* types, and the whole catalogue is a click away under the + menu.
|
|
5022
|
+
*/
|
|
5023
|
+
.slice(0, 4)
|
|
5024
|
+
.map((skill) => ({
|
|
5025
|
+
type: "skill",
|
|
5026
|
+
icon: "skill",
|
|
5027
|
+
label: skill.name,
|
|
5028
|
+
// Under the name rather than beside it. A file's sub is a folder and
|
|
5029
|
+
// fits the right-hand slot; a skill's is the sentence it is chosen on,
|
|
5030
|
+
// and in that slot it pushes the name it belongs to off the row.
|
|
5031
|
+
note: skill.description || "",
|
|
5032
|
+
ref: { kind: "skill", value: skill.name, label: skill.name },
|
|
5033
|
+
}));
|
|
5034
|
+
/**
|
|
5035
|
+
* Plugins, on the same terms as skills and after them.
|
|
5036
|
+
*
|
|
5037
|
+
* A plugin is a bundle rather than a thing the model loads, so naming one
|
|
5038
|
+
* is a weaker instruction than naming a skill — "use the ottoport plugin"
|
|
5039
|
+
* steers which tools get reached for rather than pulling in a page of
|
|
5040
|
+
* instructions. Worth offering, not worth offering first.
|
|
5041
|
+
*/
|
|
5042
|
+
const plugins = (offer.plugins || [])
|
|
5043
|
+
.filter((plugin) =>
|
|
5044
|
+
!query ||
|
|
5045
|
+
plugin.name.toLowerCase().includes(query) ||
|
|
5046
|
+
(plugin.title || "").toLowerCase().includes(query) ||
|
|
5047
|
+
(plugin.description || "").toLowerCase().includes(query),
|
|
5048
|
+
)
|
|
5049
|
+
.slice(0, 4)
|
|
5050
|
+
.map((plugin) => ({
|
|
5051
|
+
type: "plugin",
|
|
5052
|
+
icon: "plugin",
|
|
5053
|
+
label: plugin.title || plugin.name,
|
|
5054
|
+
note: plugin.description || "",
|
|
5055
|
+
ref: { kind: "plugin", value: plugin.name, label: plugin.name },
|
|
5056
|
+
}));
|
|
5057
|
+
const files = (fileList.entries || []).map((entry) => ({
|
|
4541
5058
|
type: entry.dir ? "folder" : "file",
|
|
4542
5059
|
icon: entry.dir ? "folder" : "file",
|
|
4543
5060
|
label: entry.name,
|
|
4544
5061
|
sub: folderOf(entry.path),
|
|
4545
5062
|
ref: { kind: entry.dir ? "folder" : "file", value: entry.path, label: entry.name },
|
|
4546
5063
|
}));
|
|
5064
|
+
/**
|
|
5065
|
+
* Headed, but only where a heading distinguishes something.
|
|
5066
|
+
*
|
|
5067
|
+
* Two kinds of thing share this list and they are not interchangeable —
|
|
5068
|
+
* one is a file to read, the other is a way of working the model loads.
|
|
5069
|
+
* A heading each says so. With only one kind present the heading is a
|
|
5070
|
+
* label on a list of one kind, which is noise.
|
|
5071
|
+
*/
|
|
5072
|
+
const sections = [
|
|
5073
|
+
[t("add.skills"), skills],
|
|
5074
|
+
[t("add.plugins"), plugins],
|
|
5075
|
+
[t("mention.files"), files],
|
|
5076
|
+
].filter(([, rows]) => rows.length);
|
|
5077
|
+
// One kind present is a list of one kind, and a heading over it labels
|
|
5078
|
+
// nothing. Headings appear when there is something to tell apart.
|
|
5079
|
+
if (sections.length < 2) return sections.flatMap(([, rows]) => rows);
|
|
5080
|
+
return sections.flatMap(([label, rows]) => [{ type: "group", label }, ...rows]);
|
|
4547
5081
|
}
|
|
4548
5082
|
|
|
4549
5083
|
const connectors = (offer.connectors || []).length;
|
|
4550
5084
|
const plugins = (offer.plugins || []).length;
|
|
5085
|
+
const skills = (offer.skills || []).length;
|
|
4551
5086
|
return [
|
|
4552
5087
|
// Two rows, because the dialogs behind them are two: only macOS has a
|
|
4553
5088
|
// panel that takes a file and a folder in one pass, and everywhere else a
|
|
@@ -4560,7 +5095,7 @@ function attachRows() {
|
|
|
4560
5095
|
// word in the slot that otherwise holds a number, and it reads as a value
|
|
4561
5096
|
// rather than as an absence.
|
|
4562
5097
|
{
|
|
4563
|
-
type: "
|
|
5098
|
+
type: "into",
|
|
4564
5099
|
kind: "connectors",
|
|
4565
5100
|
icon: "plug",
|
|
4566
5101
|
label: t("add.connectors"),
|
|
@@ -4568,13 +5103,21 @@ function attachRows() {
|
|
|
4568
5103
|
into: true,
|
|
4569
5104
|
},
|
|
4570
5105
|
{
|
|
4571
|
-
type: "
|
|
5106
|
+
type: "into",
|
|
4572
5107
|
kind: "plugins",
|
|
4573
5108
|
icon: "plugin",
|
|
4574
5109
|
label: t("add.plugins"),
|
|
4575
5110
|
sub: plugins ? String(plugins) : "",
|
|
4576
5111
|
into: true,
|
|
4577
5112
|
},
|
|
5113
|
+
{
|
|
5114
|
+
type: "into",
|
|
5115
|
+
kind: "skills",
|
|
5116
|
+
icon: "skill",
|
|
5117
|
+
label: t("add.skills"),
|
|
5118
|
+
sub: skills ? String(skills) : "",
|
|
5119
|
+
into: true,
|
|
5120
|
+
},
|
|
4578
5121
|
{ type: "upload", icon: "upload", label: t("add.upload") },
|
|
4579
5122
|
];
|
|
4580
5123
|
}
|
|
@@ -4582,16 +5125,28 @@ function attachRows() {
|
|
|
4582
5125
|
function paintAttachMenu() {
|
|
4583
5126
|
const rows = attachRows();
|
|
4584
5127
|
if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
|
|
5128
|
+
// The list opens with the cursor at 0, and 0 is a heading whenever the list
|
|
5129
|
+
// is grouped. Nudged here rather than at every call site that sets it.
|
|
5130
|
+
while (rows[attachCursor] && rows[attachCursor].type === "group" && attachCursor < rows.length - 1) {
|
|
5131
|
+
attachCursor++;
|
|
5132
|
+
}
|
|
4585
5133
|
attachBody.replaceChildren();
|
|
4586
5134
|
|
|
4587
5135
|
rows.forEach((row, i) => {
|
|
5136
|
+
if (row.type === "group") {
|
|
5137
|
+
attachBody.appendChild(el("div", "menu-head", row.label));
|
|
5138
|
+
return;
|
|
5139
|
+
}
|
|
5140
|
+
if (row.type === "hint") {
|
|
5141
|
+
attachBody.appendChild(el("div", "menu-hint", row.label));
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
4588
5144
|
const item = el(
|
|
4589
5145
|
"div",
|
|
4590
5146
|
"menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
|
|
4591
5147
|
);
|
|
4592
5148
|
item.setAttribute("role", "button");
|
|
4593
|
-
item.appendChild(
|
|
4594
|
-
item.appendChild(svgIcon(row.icon));
|
|
5149
|
+
item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
|
|
4595
5150
|
if (row.note) {
|
|
4596
5151
|
item.classList.add("stacked");
|
|
4597
5152
|
const stack = el("span", "stack");
|
|
@@ -4602,6 +5157,11 @@ function paintAttachMenu() {
|
|
|
4602
5157
|
item.appendChild(el("span", "menu-label", row.label));
|
|
4603
5158
|
}
|
|
4604
5159
|
if (row.sub) item.appendChild(el("span", "sub", row.sub));
|
|
5160
|
+
// At the end rather than the start. A tick that is invisible until a row
|
|
5161
|
+
// is chosen still held a column open in front of every row that was not,
|
|
5162
|
+
// which put the icons a third of the way into the menu and the words
|
|
5163
|
+
// further still. On the right it costs nothing until there is one.
|
|
5164
|
+
if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
|
|
4605
5165
|
if (row.into) item.appendChild(el("span", "chev"));
|
|
4606
5166
|
// An installed connector is removed from the row that says it is
|
|
4607
5167
|
// installed, which is the row someone looking to remove it goes to.
|
|
@@ -4634,6 +5194,26 @@ function paintAttachMenu() {
|
|
|
4634
5194
|
}
|
|
4635
5195
|
|
|
4636
5196
|
function chooseAttachRow(row) {
|
|
5197
|
+
if (row.type === "hint") return;
|
|
5198
|
+
if (row.type === "up") return void showAttachView("root");
|
|
5199
|
+
if (row.type === "into") return void showAttachView(row.kind);
|
|
5200
|
+
if (row.type === "offer-ref") {
|
|
5201
|
+
/**
|
|
5202
|
+
* The same thing, named the same way, whichever menu it was picked from.
|
|
5203
|
+
*
|
|
5204
|
+
* A skill picked with @ lands in the sentence; picked from here it used to
|
|
5205
|
+
* become a chip floating above the box instead — two representations of
|
|
5206
|
+
* one idea, and the user has to learn which menu produces which. A
|
|
5207
|
+
* connector keeps the chip: it is not something anybody writes into a
|
|
5208
|
+
* sentence, it is a capability this message is sent with.
|
|
5209
|
+
*/
|
|
5210
|
+
if (row.ref.kind === "skill" || row.ref.kind === "plugin") return void mentionAtCaret(row.ref.value);
|
|
5211
|
+
return void toggleRef(row.ref);
|
|
5212
|
+
}
|
|
5213
|
+
if (row.type === "manage") {
|
|
5214
|
+
closeAttachMenu();
|
|
5215
|
+
return void openMarket(row.kind);
|
|
5216
|
+
}
|
|
4637
5217
|
if (row.type === "pick") return void pickFromDisk(row.kind);
|
|
4638
5218
|
if (row.type === "market") {
|
|
4639
5219
|
closeAttachMenu();
|
|
@@ -4643,7 +5223,10 @@ function chooseAttachRow(row) {
|
|
|
4643
5223
|
closeAttachMenu();
|
|
4644
5224
|
return void fileInput.click();
|
|
4645
5225
|
}
|
|
4646
|
-
if (
|
|
5226
|
+
if (row.type === "command") return void insertCommand(row.command);
|
|
5227
|
+
if (attachView === "mention" && row.ref) {
|
|
5228
|
+
return void insertMention(row.ref.value);
|
|
5229
|
+
}
|
|
4647
5230
|
toggleRef(row.ref);
|
|
4648
5231
|
}
|
|
4649
5232
|
|
|
@@ -4742,14 +5325,19 @@ const marketDialog = document.getElementById("market");
|
|
|
4742
5325
|
const marketList = document.getElementById("mk-list");
|
|
4743
5326
|
let marketKind = "connectors";
|
|
4744
5327
|
|
|
5328
|
+
const marketSearch = document.getElementById("mk-search");
|
|
5329
|
+
marketSearch.addEventListener("input", () => paintMarket());
|
|
5330
|
+
|
|
4745
5331
|
async function openMarket(kind) {
|
|
4746
5332
|
marketKind = kind;
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
5333
|
+
// Cleared on open: a query left over from the last screen would hide most of
|
|
5334
|
+
// this one and look like an empty catalogue.
|
|
5335
|
+
marketSearch.value = "";
|
|
5336
|
+
marketSearch.placeholder = t("menu.search");
|
|
5337
|
+
const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors" };
|
|
5338
|
+
const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote" };
|
|
5339
|
+
document.getElementById("mk-title").textContent = t(titles[kind] || titles.connectors);
|
|
5340
|
+
document.getElementById("mk-note").textContent = t(notes[kind] || notes.connectors);
|
|
4753
5341
|
marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
|
|
4754
5342
|
marketDialog.showModal();
|
|
4755
5343
|
|
|
@@ -4764,7 +5352,18 @@ async function openMarket(kind) {
|
|
|
4764
5352
|
|
|
4765
5353
|
function paintMarket() {
|
|
4766
5354
|
marketList.replaceChildren();
|
|
4767
|
-
const
|
|
5355
|
+
const query = (marketSearch.value || "").trim().toLowerCase();
|
|
5356
|
+
const hit = (entry) =>
|
|
5357
|
+
!query ||
|
|
5358
|
+
(entry.name || "").toLowerCase().includes(query) ||
|
|
5359
|
+
(entry.title || "").toLowerCase().includes(query) ||
|
|
5360
|
+
(entry.description || "").toLowerCase().includes(query) ||
|
|
5361
|
+
(entry.label || "").toLowerCase().includes(query);
|
|
5362
|
+
const installed = (
|
|
5363
|
+
marketKind === "plugins" ? offer.plugins || []
|
|
5364
|
+
: marketKind === "skills" ? offer.skills || []
|
|
5365
|
+
: offer.connectors || []
|
|
5366
|
+
).filter(hit);
|
|
4768
5367
|
|
|
4769
5368
|
if (installed.length) {
|
|
4770
5369
|
marketList.appendChild(el("div", "mk-group", t("market.installed")));
|
|
@@ -4775,7 +5374,7 @@ function paintMarket() {
|
|
|
4775
5374
|
|
|
4776
5375
|
// Only connectors have somewhere to come from yet. Saying so beats an
|
|
4777
5376
|
// "Available" heading over an empty space that reads as a failed load.
|
|
4778
|
-
const available = marketKind === "connectors" ? market.filter((one) => !one.installed) : [];
|
|
5377
|
+
const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
|
|
4779
5378
|
if (available.length) {
|
|
4780
5379
|
marketList.appendChild(el("div", "mk-group", t("market.available")));
|
|
4781
5380
|
const grid = el("div", "mk-grid");
|
|
@@ -4870,11 +5469,37 @@ function isDarkFlatGlyph(img) {
|
|
|
4870
5469
|
function installedRow(entry) {
|
|
4871
5470
|
const row = el("div", "mk-item");
|
|
4872
5471
|
const isPlugin = marketKind === "plugins";
|
|
4873
|
-
|
|
5472
|
+
const isSkill = marketKind === "skills";
|
|
5473
|
+
row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
|
|
4874
5474
|
|
|
4875
5475
|
const naming = el("div", "mk-naming");
|
|
4876
|
-
|
|
4877
|
-
|
|
5476
|
+
// A plugin may carry a display title over its id; the id is still what a
|
|
5477
|
+
// reference resolves by, so it is the title that is painted and nothing else.
|
|
5478
|
+
const heading = el("div", "mk-name", entry.title || entry.name);
|
|
5479
|
+
/**
|
|
5480
|
+
* Where it came from, when that is not "this machine".
|
|
5481
|
+
*
|
|
5482
|
+
* A list of eighty skills is mostly things somebody installed, and the four
|
|
5483
|
+
* that ship here look exactly like them — which matters the moment one
|
|
5484
|
+
* misbehaves and the question is whose it is. Runtime plugins get the same
|
|
5485
|
+
* treatment for the same reason: memory and media are ours, and anything
|
|
5486
|
+
* beside them was configured.
|
|
5487
|
+
*/
|
|
5488
|
+
// A runtime plugin says so in its source; a skill carries its own flag. Reading
|
|
5489
|
+
// only one of them is how the two that ship here — memory and the media
|
|
5490
|
+
// plugin — ended up as the only rows in the panel with no provenance at all.
|
|
5491
|
+
const tag =
|
|
5492
|
+
entry.builtin || entry.source === "builtin" ? "built-in"
|
|
5493
|
+
: entry.source === "runtime" ? "runtime"
|
|
5494
|
+
: "";
|
|
5495
|
+
if (tag) heading.appendChild(el("span", "mk-tag", tag));
|
|
5496
|
+
naming.appendChild(heading);
|
|
5497
|
+
// A skill has nothing to connect to and nothing to be offline from, so the
|
|
5498
|
+
// slot that carries a connector's state carries what the skill is for —
|
|
5499
|
+
// which is also the sentence the model decides on.
|
|
5500
|
+
const detail = isSkill
|
|
5501
|
+
? entry.description || entry.source
|
|
5502
|
+
: isPlugin
|
|
4878
5503
|
? entry.description || entry.source
|
|
4879
5504
|
: entry.connected
|
|
4880
5505
|
? t("add.tools", { n: entry.tools })
|
|
@@ -4896,7 +5521,7 @@ function installedRow(entry) {
|
|
|
4896
5521
|
const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
|
|
4897
5522
|
acts.appendChild(el("span", "mk-state", said));
|
|
4898
5523
|
}
|
|
4899
|
-
const ref = { kind: isPlugin ? "plugin" : "connector", value: entry.name, label: entry.name };
|
|
5524
|
+
const ref = { kind: isPlugin ? "plugin" : isSkill ? "skill" : "connector", value: entry.name, label: entry.name };
|
|
4900
5525
|
const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
|
|
4901
5526
|
attach.type = "button";
|
|
4902
5527
|
attach.title = t("market.attach");
|
|
@@ -4908,7 +5533,7 @@ function installedRow(entry) {
|
|
|
4908
5533
|
|
|
4909
5534
|
// A connector that asked to be signed in to gets the one button that moves
|
|
4910
5535
|
// it forward, in front of the one that removes it.
|
|
4911
|
-
if (!isPlugin && entry.needsAuth) {
|
|
5536
|
+
if (!isPlugin && !isSkill && entry.needsAuth) {
|
|
4912
5537
|
const go = el(
|
|
4913
5538
|
"button",
|
|
4914
5539
|
"mk-btn go",
|
|
@@ -4920,7 +5545,9 @@ function installedRow(entry) {
|
|
|
4920
5545
|
acts.appendChild(go);
|
|
4921
5546
|
}
|
|
4922
5547
|
|
|
4923
|
-
|
|
5548
|
+
// Removal is a connector's alone: a plugin is a directory on disk and a
|
|
5549
|
+
// skill is a file in one, and neither is undone from a composer menu.
|
|
5550
|
+
if (!isPlugin && !isSkill) {
|
|
4924
5551
|
const drop = el("button", "mk-icon-btn drop", "×");
|
|
4925
5552
|
drop.type = "button";
|
|
4926
5553
|
drop.title = t("add.remove");
|
|
@@ -4972,7 +5599,7 @@ async function installConnector(id, remove) {
|
|
|
4972
5599
|
addNotice(body.error, "err");
|
|
4973
5600
|
return void paintMarket();
|
|
4974
5601
|
}
|
|
4975
|
-
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [] };
|
|
5602
|
+
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
|
|
4976
5603
|
for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
|
|
4977
5604
|
paintMarket();
|
|
4978
5605
|
}
|
|
@@ -5001,6 +5628,8 @@ async function authorizeConnector(id) {
|
|
|
5001
5628
|
addNotice(t("market.authOpened", { name: id }));
|
|
5002
5629
|
}
|
|
5003
5630
|
|
|
5631
|
+
document.getElementById("mk-close").appendChild(svgIcon("close"));
|
|
5632
|
+
document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
|
|
5004
5633
|
document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
|
|
5005
5634
|
|
|
5006
5635
|
async function loadMentionFiles() {
|
|
@@ -5054,12 +5683,33 @@ function openAttachMenu() {
|
|
|
5054
5683
|
// projects changes what is connected, and a connector can drop between two
|
|
5055
5684
|
// messages.
|
|
5056
5685
|
void getJson("/context").then((body) => {
|
|
5057
|
-
if (body.error
|
|
5686
|
+
if (body.error) return;
|
|
5687
|
+
offerLoaded = true;
|
|
5688
|
+
if (attachMenu.hidden) return;
|
|
5058
5689
|
offer = body;
|
|
5059
5690
|
paintAttachMenu();
|
|
5060
5691
|
});
|
|
5061
5692
|
}
|
|
5062
5693
|
|
|
5694
|
+
/**
|
|
5695
|
+
* The same list, for a mention that was never opened from the + button.
|
|
5696
|
+
*
|
|
5697
|
+
* The + menu refreshes on every open because a connector can drop between two
|
|
5698
|
+
* messages. A mention cannot do that: it reopens on every keystroke, and a
|
|
5699
|
+
* fetch per character is a fetch per character. So it asks once, and a skill
|
|
5700
|
+
* list that is a few minutes stale is the right trade against a skill list
|
|
5701
|
+
* that is empty because nobody happened to click the +.
|
|
5702
|
+
*/
|
|
5703
|
+
function ensureOffer() {
|
|
5704
|
+
if (offerLoaded) return;
|
|
5705
|
+
offerLoaded = true;
|
|
5706
|
+
void getJson("/context").then((body) => {
|
|
5707
|
+
if (body.error) return;
|
|
5708
|
+
offer = body;
|
|
5709
|
+
if (attachView === "mention") paintAttachMenu();
|
|
5710
|
+
});
|
|
5711
|
+
}
|
|
5712
|
+
|
|
5063
5713
|
/**
|
|
5064
5714
|
* Arrow keys and Enter over the rows, shared by the menu and the mention list.
|
|
5065
5715
|
*
|
|
@@ -5071,13 +5721,21 @@ function moveAttachCursor(e) {
|
|
|
5071
5721
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
5072
5722
|
e.preventDefault();
|
|
5073
5723
|
if (!rows.length) return true;
|
|
5074
|
-
|
|
5724
|
+
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
5725
|
+
// Past the headings rather than onto them: a heading is not a choice, and
|
|
5726
|
+
// a cursor resting on one is a keypress that appears to do nothing.
|
|
5727
|
+
let next = attachCursor;
|
|
5728
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5729
|
+
next = (next + step + rows.length) % rows.length;
|
|
5730
|
+
if (rows[next].type !== "group") break;
|
|
5731
|
+
}
|
|
5732
|
+
attachCursor = next;
|
|
5075
5733
|
paintAttachMenu();
|
|
5076
5734
|
const active = attachBody.querySelector(".cursor");
|
|
5077
5735
|
if (active) active.scrollIntoView({ block: "nearest" });
|
|
5078
5736
|
return true;
|
|
5079
5737
|
}
|
|
5080
|
-
if (e.key === "Enter" || (e.key === "Tab" && attachView === "mention")) {
|
|
5738
|
+
if (e.key === "Enter" || (e.key === "Tab" && (attachView === "mention" || attachView === "command"))) {
|
|
5081
5739
|
e.preventDefault();
|
|
5082
5740
|
const row = rows[attachCursor];
|
|
5083
5741
|
if (row) chooseAttachRow(row);
|
|
@@ -5086,6 +5744,232 @@ function moveAttachCursor(e) {
|
|
|
5086
5744
|
return false;
|
|
5087
5745
|
}
|
|
5088
5746
|
|
|
5747
|
+
// --- the chip behind a mention ---------------------------------------------
|
|
5748
|
+
|
|
5749
|
+
const inputMirror = document.getElementById("input-mirror");
|
|
5750
|
+
|
|
5751
|
+
/** Every name an @ can stand for. */
|
|
5752
|
+
function mentionNames() {
|
|
5753
|
+
return new Map([
|
|
5754
|
+
...(offer.skills || []).map((skill) => [skill.name, "skill"]),
|
|
5755
|
+
...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
|
|
5756
|
+
]);
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
/**
|
|
5760
|
+
* Backspace takes the whole mention, not a letter of it.
|
|
5761
|
+
*
|
|
5762
|
+
* The chip is drawn as one object and behaves as one everywhere else — it is
|
|
5763
|
+
* inserted whole and sent whole — so deleting it a character at a time leaves
|
|
5764
|
+
* a trail of half-names that are no longer anything, each of them still
|
|
5765
|
+
* looking like a word the harness understood until the wash disappears. Only
|
|
5766
|
+
* when the caret is against it and nothing is selected: a selection is the
|
|
5767
|
+
* user being specific, and editing inside a name is allowed, it just is not
|
|
5768
|
+
* what a keystroke at the edge means.
|
|
5769
|
+
*/
|
|
5770
|
+
input.addEventListener("keydown", (e) => {
|
|
5771
|
+
if (e.key !== "Backspace" && e.key !== "Delete") return;
|
|
5772
|
+
if (input.selectionStart !== input.selectionEnd) return;
|
|
5773
|
+
const caret = input.selectionStart;
|
|
5774
|
+
const names = mentionNames();
|
|
5775
|
+
if (!names.size) return;
|
|
5776
|
+
|
|
5777
|
+
let from = caret;
|
|
5778
|
+
let to = caret;
|
|
5779
|
+
if (e.key === "Backspace") {
|
|
5780
|
+
const hit = /(^|\s)@([^\s@]+)$/.exec(input.value.slice(0, caret));
|
|
5781
|
+
if (!hit || !names.has(hit[2])) return;
|
|
5782
|
+
from = caret - hit[2].length - 1;
|
|
5783
|
+
} else {
|
|
5784
|
+
const after = input.value.slice(caret);
|
|
5785
|
+
const hit = /^@([^\s@]+)/.exec(after);
|
|
5786
|
+
// Forward-delete only from the start of one: mid-name it is an ordinary
|
|
5787
|
+
// character, and the caret is visibly inside a word.
|
|
5788
|
+
const start = caret === 0 || /\s/.test(input.value[caret - 1] ?? "");
|
|
5789
|
+
if (!hit || !start || !names.has(hit[1])) return;
|
|
5790
|
+
to = caret + hit[1].length + 1;
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5793
|
+
e.preventDefault();
|
|
5794
|
+
input.value = input.value.slice(0, from) + input.value.slice(to);
|
|
5795
|
+
input.setSelectionRange(from, from);
|
|
5796
|
+
paintMirror();
|
|
5797
|
+
// The box may now fit in fewer lines, and the grower only runs on input.
|
|
5798
|
+
input.style.height = "auto";
|
|
5799
|
+
input.style.height = Math.min(Math.max(input.scrollHeight, 56), window.innerHeight * 0.32) + "px";
|
|
5800
|
+
});
|
|
5801
|
+
|
|
5802
|
+
/**
|
|
5803
|
+
* Paint the backdrop: the same text, with the mentions in it marked.
|
|
5804
|
+
*
|
|
5805
|
+
* Only names that are actually a skill or a plugin are marked. Highlighting
|
|
5806
|
+
* any @word would promise something about an email address and a typo, and
|
|
5807
|
+
* the promise this makes — that the harness knows what was named — is exactly
|
|
5808
|
+
* what a reader is checking when they glance at it.
|
|
5809
|
+
*/
|
|
5810
|
+
function paintMirror() {
|
|
5811
|
+
if (!inputMirror) return;
|
|
5812
|
+
// Typing an @ asks for the list, but text can also arrive whole — pasted, or
|
|
5813
|
+
// restored as a draft — and that text has mentions in it too. Guarded, so
|
|
5814
|
+
// this is one request in the life of the page rather than one per keystroke.
|
|
5815
|
+
ensureOffer();
|
|
5816
|
+
const names = mentionNames();
|
|
5817
|
+
inputMirror.replaceChildren();
|
|
5818
|
+
if (!names.size) return void (inputMirror.textContent = input.value);
|
|
5819
|
+
|
|
5820
|
+
const text = input.value;
|
|
5821
|
+
let at = 0;
|
|
5822
|
+
// Word-initial, matching what the mention parser accepts, so what lights up
|
|
5823
|
+
// is what the menu would have offered.
|
|
5824
|
+
const pattern = /(^|\s)@([^\s@]+)/g;
|
|
5825
|
+
for (let hit = pattern.exec(text); hit; hit = pattern.exec(text)) {
|
|
5826
|
+
const start = hit.index + hit[1].length;
|
|
5827
|
+
if (!names.has(hit[2])) continue;
|
|
5828
|
+
inputMirror.appendChild(document.createTextNode(text.slice(at, start)));
|
|
5829
|
+
const mark = el("mark", "");
|
|
5830
|
+
const slot = el("span", "at");
|
|
5831
|
+
slot.appendChild(svgIcon(names.get(hit[2])));
|
|
5832
|
+
mark.appendChild(slot);
|
|
5833
|
+
mark.appendChild(document.createTextNode(hit[2]));
|
|
5834
|
+
inputMirror.appendChild(mark);
|
|
5835
|
+
at = start + hit[2].length + 1;
|
|
5836
|
+
}
|
|
5837
|
+
inputMirror.appendChild(document.createTextNode(text.slice(at)));
|
|
5838
|
+
if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
|
|
5839
|
+
}
|
|
5840
|
+
|
|
5841
|
+
/** How wide the field draws an @ sign: the slot the icon has to fit in. */
|
|
5842
|
+
function atWidth() {
|
|
5843
|
+
const probe = document.createElement("canvas").getContext("2d");
|
|
5844
|
+
probe.font = getComputedStyle(input).font;
|
|
5845
|
+
return probe.measureText("@").width;
|
|
5846
|
+
}
|
|
5847
|
+
|
|
5848
|
+
// The mirror has no scrollbar of its own; the text it holds is the text the
|
|
5849
|
+
// textarea is scrolling.
|
|
5850
|
+
input.addEventListener("scroll", () => {
|
|
5851
|
+
if (inputMirror) inputMirror.scrollTop = input.scrollTop;
|
|
5852
|
+
});
|
|
5853
|
+
|
|
5854
|
+
// --- / commands ------------------------------------------------------------
|
|
5855
|
+
|
|
5856
|
+
/**
|
|
5857
|
+
* What a slash does that the sidebar does not.
|
|
5858
|
+
*
|
|
5859
|
+
* The palette on Cmd-K is navigation: go somewhere, open something. These are
|
|
5860
|
+
* the other half — act on the session in front of you — and they are here
|
|
5861
|
+
* because that half had no surface at all. A command that only opens a panel
|
|
5862
|
+
* belongs in the palette, not in this list; two ways to reach the same screen
|
|
5863
|
+
* is how a user ends up unsure which one has the thing they want.
|
|
5864
|
+
*
|
|
5865
|
+
* Each takes the rest of the line as its argument, which is why the menu
|
|
5866
|
+
* inserts a trailing space: the next thing typed is the argument, not a
|
|
5867
|
+
* search for another command.
|
|
5868
|
+
*/
|
|
5869
|
+
const COMMANDS = [
|
|
5870
|
+
{
|
|
5871
|
+
name: "model",
|
|
5872
|
+
arg: "id",
|
|
5873
|
+
hint: () => t("cmd.model"),
|
|
5874
|
+
run: (arg) => {
|
|
5875
|
+
if (!arg) return void document.querySelector("#model-picker .picker-btn")?.click();
|
|
5876
|
+
return void api("/model", { model: arg }).then(pushNotice);
|
|
5877
|
+
},
|
|
5878
|
+
},
|
|
5879
|
+
{
|
|
5880
|
+
name: "mode",
|
|
5881
|
+
arg: "mode",
|
|
5882
|
+
hint: () => t("cmd.mode"),
|
|
5883
|
+
run: (arg) => {
|
|
5884
|
+
if (!arg) return void document.querySelector("#mode-picker .picker-btn")?.click();
|
|
5885
|
+
return void api("/mode", { mode: arg }).then(pushNotice);
|
|
5886
|
+
},
|
|
5887
|
+
},
|
|
5888
|
+
{
|
|
5889
|
+
name: "rename",
|
|
5890
|
+
arg: "title",
|
|
5891
|
+
hint: () => t("cmd.rename"),
|
|
5892
|
+
run: (arg) => {
|
|
5893
|
+
if (!arg) return void addNotice(t("cmd.needsTitle"), "notice err");
|
|
5894
|
+
return void api("/rename", { id: activeId, title: arg }).then(pushNotice);
|
|
5895
|
+
},
|
|
5896
|
+
},
|
|
5897
|
+
{
|
|
5898
|
+
name: "schedule",
|
|
5899
|
+
arg: "name",
|
|
5900
|
+
hint: () => t("cmd.schedule"),
|
|
5901
|
+
run: (arg) => {
|
|
5902
|
+
// The panel manages routines; this runs one without leaving the
|
|
5903
|
+
// composer. Creating one from here would need somewhere to keep it,
|
|
5904
|
+
// which is the panel's job and not a line of text's.
|
|
5905
|
+
if (!arg) return void document.getElementById("nav-routines")?.click();
|
|
5906
|
+
return void api("/routine", { name: arg }).then(pushNotice);
|
|
5907
|
+
},
|
|
5908
|
+
},
|
|
5909
|
+
{
|
|
5910
|
+
name: "new",
|
|
5911
|
+
arg: "",
|
|
5912
|
+
hint: () => t("cmd.new"),
|
|
5913
|
+
run: () => void switchTo({ action: "new" }),
|
|
5914
|
+
},
|
|
5915
|
+
];
|
|
5916
|
+
|
|
5917
|
+
function pushNotice(body) {
|
|
5918
|
+
if (body && body.error) addNotice(body.error, "notice err");
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
/** The /word being typed, or null. Only ever the first word of the box. */
|
|
5922
|
+
function commandUnderCaret() {
|
|
5923
|
+
// Anchored at the start, and only while the first word is still being
|
|
5924
|
+
// typed: a path is full of slashes and "and/or" is a word, and neither is
|
|
5925
|
+
// an attempt to run something.
|
|
5926
|
+
const before = input.value.slice(0, input.selectionStart);
|
|
5927
|
+
const match = /^\/([a-z-]*)$/i.exec(before);
|
|
5928
|
+
return match ? { query: match[1] } : null;
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5931
|
+
function openCommands(command) {
|
|
5932
|
+
ensureOffer();
|
|
5933
|
+
attachView = "command";
|
|
5934
|
+
attachQuery = command.query.trim().toLowerCase();
|
|
5935
|
+
attachCursor = 0;
|
|
5936
|
+
attachMenu.hidden = false;
|
|
5937
|
+
attachPicker.classList.add("open");
|
|
5938
|
+
const bar = attachPicker.getBoundingClientRect().top;
|
|
5939
|
+
const box = composerBox.getBoundingClientRect().top;
|
|
5940
|
+
// On a phone the menu is positioned from the box itself by the stylesheet.
|
|
5941
|
+
attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
|
|
5942
|
+
paintAttachMenu();
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5945
|
+
/** Insert the command, with the space its argument starts after. */
|
|
5946
|
+
function insertCommand(name) {
|
|
5947
|
+
const rest = input.value.slice(input.selectionStart);
|
|
5948
|
+
input.value = "/" + name + " " + rest.replace(/^\s+/, "");
|
|
5949
|
+
paintMirror();
|
|
5950
|
+
closeAttachMenu();
|
|
5951
|
+
input.focus();
|
|
5952
|
+
const at = name.length + 2;
|
|
5953
|
+
input.setSelectionRange(at, at);
|
|
5954
|
+
}
|
|
5955
|
+
|
|
5956
|
+
/**
|
|
5957
|
+
* Run what was typed, or say it is not a command.
|
|
5958
|
+
*
|
|
5959
|
+
* Returns whether the line was handled, so the submit path can tell a command
|
|
5960
|
+
* from a prompt. An unknown slash word is a prompt: people write /etc and
|
|
5961
|
+
* and/or, and refusing to send those would be the feature getting in the way
|
|
5962
|
+
* of the thing it was added to.
|
|
5963
|
+
*/
|
|
5964
|
+
function runCommand(text) {
|
|
5965
|
+
const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/i.exec(text);
|
|
5966
|
+
if (!match) return false;
|
|
5967
|
+
const command = COMMANDS.find((one) => one.name === match[1].toLowerCase());
|
|
5968
|
+
if (!command) return false;
|
|
5969
|
+
command.run((match[2] || "").trim());
|
|
5970
|
+
return true;
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5089
5973
|
// --- @ mentions ------------------------------------------------------------
|
|
5090
5974
|
|
|
5091
5975
|
/** The @word being typed at the caret, or null. */
|
|
@@ -5099,6 +5983,7 @@ function mentionUnderCaret() {
|
|
|
5099
5983
|
}
|
|
5100
5984
|
|
|
5101
5985
|
function openMention(mention) {
|
|
5986
|
+
ensureOffer();
|
|
5102
5987
|
mentionAt = mention.at;
|
|
5103
5988
|
attachView = "mention";
|
|
5104
5989
|
attachQuery = mention.query.trim();
|
|
@@ -5110,15 +5995,51 @@ function openMention(mention) {
|
|
|
5110
5995
|
// the sentence it is going into.
|
|
5111
5996
|
const bar = attachPicker.getBoundingClientRect().top;
|
|
5112
5997
|
const box = composerBox.getBoundingClientRect().top;
|
|
5113
|
-
|
|
5998
|
+
// On a phone the menu is positioned from the box itself by the stylesheet.
|
|
5999
|
+
attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
|
|
5114
6000
|
paintAttachMenu();
|
|
5115
6001
|
searchMentionFiles();
|
|
5116
6002
|
}
|
|
5117
6003
|
|
|
6004
|
+
/** Write an @name into the box at the caret, from a menu that is not the @ one. */
|
|
6005
|
+
function mentionAtCaret(name) {
|
|
6006
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
6007
|
+
const before = input.value.slice(0, caret);
|
|
6008
|
+
const head = roomBefore(before) + "@" + name + " ";
|
|
6009
|
+
input.value = head + input.value.slice(caret);
|
|
6010
|
+
closeAttachMenu();
|
|
6011
|
+
input.focus();
|
|
6012
|
+
input.setSelectionRange(head.length, head.length);
|
|
6013
|
+
paintMirror();
|
|
6014
|
+
}
|
|
6015
|
+
|
|
6016
|
+
/**
|
|
6017
|
+
* Two spaces on either side of a mention, not one.
|
|
6018
|
+
*
|
|
6019
|
+
* The chip's horizontal padding is taken out of the whitespace next to it,
|
|
6020
|
+
* because the field decides where every glyph lands and the mirror cannot add
|
|
6021
|
+
* width of its own. One space is four pixels: enough for the wash to clear
|
|
6022
|
+
* the letters, not enough for the chip to read as an object with room around
|
|
6023
|
+
* it. A second space gives four pixels of padding and four more between the
|
|
6024
|
+
* chip and the next word; three between two chips, which each take four of
|
|
6025
|
+
* them. The model sees the extra spaces, which it reads the same as one.
|
|
6026
|
+
*/
|
|
6027
|
+
function roomBefore(before) {
|
|
6028
|
+
if (!before || /\n$/.test(before)) return before;
|
|
6029
|
+
const bare = before.replace(/ +$/, "");
|
|
6030
|
+
// Two chips in a row each take four pixels of the gap between them, so
|
|
6031
|
+
// two spaces would leave them touching. A third keeps the same four pixels
|
|
6032
|
+
// of daylight a chip has from a word.
|
|
6033
|
+
const last = /@([^\s@]+)$/.exec(bare);
|
|
6034
|
+
const afterChip = last && mentionNames().has(last[1]);
|
|
6035
|
+
return bare + (afterChip ? " " : " ");
|
|
6036
|
+
}
|
|
6037
|
+
|
|
5118
6038
|
function insertMention(path) {
|
|
5119
6039
|
const caret = input.selectionStart;
|
|
5120
|
-
const head = input.value.slice(0, mentionAt) + "@" + path + "
|
|
6040
|
+
const head = roomBefore(input.value.slice(0, mentionAt)) + "@" + path + " ";
|
|
5121
6041
|
input.value = head + input.value.slice(caret);
|
|
6042
|
+
paintMirror();
|
|
5122
6043
|
closeAttachMenu();
|
|
5123
6044
|
input.focus();
|
|
5124
6045
|
input.setSelectionRange(head.length, head.length);
|
|
@@ -5128,9 +6049,11 @@ function insertMention(path) {
|
|
|
5128
6049
|
}
|
|
5129
6050
|
|
|
5130
6051
|
input.addEventListener("input", () => {
|
|
6052
|
+
const command = commandUnderCaret();
|
|
6053
|
+
if (command) return void openCommands(command);
|
|
5131
6054
|
const mention = mentionUnderCaret();
|
|
5132
6055
|
if (mention) return void openMention(mention);
|
|
5133
|
-
if (attachView === "mention") closeAttachMenu();
|
|
6056
|
+
if (attachView === "mention" || attachView === "command") closeAttachMenu();
|
|
5134
6057
|
});
|
|
5135
6058
|
|
|
5136
6059
|
// Before the composer's own Enter handling, which would otherwise send the
|
|
@@ -5166,6 +6089,14 @@ document.addEventListener("keydown", (e) => {
|
|
|
5166
6089
|
document.getElementById("composer").addEventListener("submit", (e) => {
|
|
5167
6090
|
e.preventDefault();
|
|
5168
6091
|
const text = input.value.trim();
|
|
6092
|
+
// A command runs instead of being sent. Unknown slash words fall through and
|
|
6093
|
+
// are sent as prose, because /etc and and/or are things people write.
|
|
6094
|
+
if (runCommand(text)) {
|
|
6095
|
+
input.value = "";
|
|
6096
|
+
input.style.height = "auto";
|
|
6097
|
+
closeAttachMenu();
|
|
6098
|
+
return;
|
|
6099
|
+
}
|
|
5169
6100
|
if (!text && !staged.length && !refs.length) return;
|
|
5170
6101
|
const ids = staged.map((file) => file.id);
|
|
5171
6102
|
const shots = staged
|
|
@@ -5175,6 +6106,7 @@ document.getElementById("composer").addEventListener("submit", (e) => {
|
|
|
5175
6106
|
const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
|
|
5176
6107
|
input.value = "";
|
|
5177
6108
|
input.style.height = "auto";
|
|
6109
|
+
paintMirror();
|
|
5178
6110
|
staged = [];
|
|
5179
6111
|
refs = [];
|
|
5180
6112
|
renderAttachments();
|
|
@@ -5201,16 +6133,62 @@ input.addEventListener("input", () => {
|
|
|
5201
6133
|
input.style.height = "auto";
|
|
5202
6134
|
const wanted = Math.max(input.scrollHeight, 56);
|
|
5203
6135
|
input.style.height = Math.min(wanted, window.innerHeight * 0.32) + "px";
|
|
6136
|
+
paintMirror();
|
|
5204
6137
|
});
|
|
5205
6138
|
|
|
6139
|
+
// --- the mention chip under the text ------------------------------------
|
|
6140
|
+
|
|
6141
|
+
|
|
5206
6142
|
stopBtn.addEventListener("click", () => void api("/interrupt", { session: activeId }));
|
|
5207
6143
|
// Straight into a session, in the tree already open. Where it runs is asked on
|
|
5208
6144
|
// the composer instead of on the way in: the project on screen is the answer
|
|
5209
6145
|
// almost every time, and a dialog in front of every new session charges the
|
|
5210
6146
|
// common case for the rare one.
|
|
5211
6147
|
document.getElementById("new").addEventListener("click", () => void switchTo({ action: "new" }));
|
|
6148
|
+
/**
|
|
6149
|
+
* The sidebar as a drawer, on a phone.
|
|
6150
|
+
*
|
|
6151
|
+
* Open from the burger, closed from the scrim, the mark in its own corner,
|
|
6152
|
+
* Escape, or by choosing anything in it: a drawer that stayed over the
|
|
6153
|
+
* transcript after a session was picked hid the very thing that was picked.
|
|
6154
|
+
*/
|
|
6155
|
+
const sidebar = document.querySelector("body > aside");
|
|
6156
|
+
function setNavOpen(open) {
|
|
6157
|
+
sidebar.classList.toggle("open", open);
|
|
6158
|
+
document.body.classList.toggle("nav-open", open);
|
|
6159
|
+
}
|
|
6160
|
+
function closeNav() {
|
|
6161
|
+
if (sidebar.classList.contains("open")) setNavOpen(false);
|
|
6162
|
+
}
|
|
5212
6163
|
document.getElementById("burger").addEventListener("click", () =>
|
|
5213
|
-
|
|
6164
|
+
setNavOpen(!sidebar.classList.contains("open")));
|
|
6165
|
+
document.getElementById("nav-close").addEventListener("click", () => setNavOpen(false));
|
|
6166
|
+
document.getElementById("scrim").addEventListener("click", () => setNavOpen(false));
|
|
6167
|
+
sidebar.addEventListener("click", (e) => {
|
|
6168
|
+
if (!isPhone() || e.target.closest(".more")) return;
|
|
6169
|
+
if (e.target.closest(".session, .nav-item, .project")) closeNav();
|
|
6170
|
+
});
|
|
6171
|
+
document.addEventListener("keydown", (e) => {
|
|
6172
|
+
if (e.key === "Escape" && sidebar.classList.contains("open")) closeNav();
|
|
6173
|
+
});
|
|
6174
|
+
document.getElementById("review-close").addEventListener("click", () =>
|
|
6175
|
+
review.classList.remove("open"));
|
|
6176
|
+
|
|
6177
|
+
// The keyboard takes the bottom of the screen and iOS does not shrink the
|
|
6178
|
+
// layout to match, so the composer would sit under it. The body follows the
|
|
6179
|
+
// visible viewport instead, on phones only.
|
|
6180
|
+
if (window.visualViewport) {
|
|
6181
|
+
const followKeyboard = () => {
|
|
6182
|
+
if (!isPhone()) {
|
|
6183
|
+
document.body.style.height = "";
|
|
6184
|
+
return;
|
|
6185
|
+
}
|
|
6186
|
+
document.body.style.height = Math.round(window.visualViewport.height) + "px";
|
|
6187
|
+
window.scrollTo(0, 0);
|
|
6188
|
+
};
|
|
6189
|
+
window.visualViewport.addEventListener("resize", followKeyboard);
|
|
6190
|
+
window.addEventListener("resize", followKeyboard);
|
|
6191
|
+
}
|
|
5214
6192
|
|
|
5215
6193
|
|
|
5216
6194
|
// --- review pane (the Codex-shaped part) ----------------------------------
|
|
@@ -6783,6 +7761,31 @@ function discoverNow(providerId) {
|
|
|
6783
7761
|
});
|
|
6784
7762
|
}
|
|
6785
7763
|
|
|
7764
|
+
/** A plus in a ring: adding credits. Drawn like the sidebar's other line icons. */
|
|
7765
|
+
function iconTopUp() {
|
|
7766
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
7767
|
+
const svg = document.createElementNS(NS, "svg");
|
|
7768
|
+
svg.setAttribute("class", "ico");
|
|
7769
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
7770
|
+
svg.setAttribute("fill", "none");
|
|
7771
|
+
svg.setAttribute("stroke", "currentColor");
|
|
7772
|
+
svg.setAttribute("stroke-width", "1.5");
|
|
7773
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
7774
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
7775
|
+
svg.setAttribute("aria-hidden", "true");
|
|
7776
|
+
const ring = document.createElementNS(NS, "circle");
|
|
7777
|
+
ring.setAttribute("cx", "8");
|
|
7778
|
+
ring.setAttribute("cy", "8");
|
|
7779
|
+
ring.setAttribute("r", "6.67");
|
|
7780
|
+
svg.appendChild(ring);
|
|
7781
|
+
for (const d of ["M8 5.33v5.34", "M5.33 8h5.34"]) {
|
|
7782
|
+
const path = document.createElementNS(NS, "path");
|
|
7783
|
+
path.setAttribute("d", d);
|
|
7784
|
+
svg.appendChild(path);
|
|
7785
|
+
}
|
|
7786
|
+
return svg;
|
|
7787
|
+
}
|
|
7788
|
+
|
|
6786
7789
|
/** A person, drawn to match the sidebar's other 16px line icons. */
|
|
6787
7790
|
function iconPerson() {
|
|
6788
7791
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
@@ -6820,6 +7823,15 @@ function iconPerson() {
|
|
|
6820
7823
|
* and none of those can be hosted in a local window — they keep working here
|
|
6821
7824
|
* precisely because this does not reimplement them.
|
|
6822
7825
|
*/
|
|
7826
|
+
/**
|
|
7827
|
+
* The teams the account is on, as the last account load found them.
|
|
7828
|
+
*
|
|
7829
|
+
* Kept here so the paywall can name a team by its id without a second
|
|
7830
|
+
* request: the id is what the billing answer carries, and the name is what
|
|
7831
|
+
* a person reads.
|
|
7832
|
+
*/
|
|
7833
|
+
let knownTeams = [];
|
|
7834
|
+
|
|
6823
7835
|
async function loadAccount() {
|
|
6824
7836
|
const host = document.getElementById("account");
|
|
6825
7837
|
if (!host) return;
|
|
@@ -6884,6 +7896,18 @@ async function loadAccount() {
|
|
|
6884
7896
|
// Whether there is a shop at all. A self-hosted gateway sells nothing, and
|
|
6885
7897
|
// an entry that opens a paywall with no plans in it is worse than no entry.
|
|
6886
7898
|
let sellsCredits = false;
|
|
7899
|
+
// Whether the plan is live. This decides which billing action is primary in
|
|
7900
|
+
// the account card: Subscribe before a plan, Top up afterwards.
|
|
7901
|
+
let subscribed = false;
|
|
7902
|
+
// The team this machine bills to, once the gateway has said what it has.
|
|
7903
|
+
// A team the account was removed from comes back refused, and the line
|
|
7904
|
+
// says so rather than showing a number it could not read.
|
|
7905
|
+
let teamLine = null;
|
|
7906
|
+
knownTeams = Array.isArray(body.teams) ? body.teams : [];
|
|
7907
|
+
const teamNameOf = (id) => {
|
|
7908
|
+
const found = knownTeams.find((team) => team.id === id);
|
|
7909
|
+
return found ? found.name : t("account.teamUnnamed");
|
|
7910
|
+
};
|
|
6887
7911
|
void getJson("/billing").then((state) => {
|
|
6888
7912
|
if (!state || !state.signedIn || state.unavailable) return;
|
|
6889
7913
|
sellsCredits = true;
|
|
@@ -6892,8 +7916,28 @@ async function loadAccount() {
|
|
|
6892
7916
|
}
|
|
6893
7917
|
const sub = state.subscription;
|
|
6894
7918
|
plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
|
|
7919
|
+
subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
|
|
7920
|
+
if (state.team) {
|
|
7921
|
+
teamLine = state.team.refused
|
|
7922
|
+
? t("account.teamRefused", { team: teamNameOf(state.team.id) })
|
|
7923
|
+
: t("account.team", {
|
|
7924
|
+
team: teamNameOf(state.team.id),
|
|
7925
|
+
credits: creditsOf(state.team.balanceMicro || 0),
|
|
7926
|
+
});
|
|
7927
|
+
}
|
|
6895
7928
|
});
|
|
6896
7929
|
|
|
7930
|
+
// Switch whose credits the next call spends. Said out loud, because the
|
|
7931
|
+
// only other sign is a different number in this menu next time it opens.
|
|
7932
|
+
const billTo = (id) => {
|
|
7933
|
+
closeRowMenu();
|
|
7934
|
+
void api("/team", { team: id }).then((result) => {
|
|
7935
|
+
if (!result || result.error) return void addNotice((result && result.error) || "could not switch", "err");
|
|
7936
|
+
addNotice(id ? t("account.usingTeam", { team: teamNameOf(id) }) : t("account.usingOwn"));
|
|
7937
|
+
void loadAccount();
|
|
7938
|
+
});
|
|
7939
|
+
};
|
|
7940
|
+
|
|
6897
7941
|
row.addEventListener("click", (e) => {
|
|
6898
7942
|
e.stopPropagation();
|
|
6899
7943
|
if (menuRow === row) return closeRowMenu();
|
|
@@ -6902,36 +7946,79 @@ async function loadAccount() {
|
|
|
6902
7946
|
// dim lines the size of a footnote, above the things to click — which is
|
|
6903
7947
|
// backwards: the balance is why somebody opened this menu, and the plan
|
|
6904
7948
|
// is what decides which of the entries below is the one they want.
|
|
6905
|
-
if (balance || plan) {
|
|
7949
|
+
if (balance || plan || teamLine) {
|
|
6906
7950
|
const head = el("div", "menu-head");
|
|
6907
7951
|
if (balance) head.appendChild(el("div", "menu-head-main", balance));
|
|
6908
7952
|
if (plan) head.appendChild(el("div", "menu-head-sub", plan));
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
menuItem(t("account.getCredits"), {
|
|
6919
|
-
run: () => {
|
|
7953
|
+
// Under the person's own line: it is the second balance in play, and
|
|
7954
|
+
// the one the next call actually draws on while a team is chosen.
|
|
7955
|
+
if (teamLine) head.appendChild(el("div", "menu-head-sub", teamLine));
|
|
7956
|
+
if (sellsCredits) {
|
|
7957
|
+
const actions = el("div", "menu-billing-actions");
|
|
7958
|
+
if (!subscribed) {
|
|
7959
|
+
const subscribe = el("button", "menu-billing-action primary", t("paywall.subscribe"));
|
|
7960
|
+
subscribe.type = "button";
|
|
7961
|
+
subscribe.addEventListener("click", () => {
|
|
6920
7962
|
closeRowMenu();
|
|
6921
7963
|
openPaywall(true);
|
|
6922
|
-
}
|
|
6923
|
-
|
|
6924
|
-
|
|
7964
|
+
});
|
|
7965
|
+
actions.appendChild(subscribe);
|
|
7966
|
+
}
|
|
7967
|
+
const topUp = el("button", "menu-billing-action" + (subscribed ? " primary" : ""), t("paywall.topUp"));
|
|
7968
|
+
topUp.type = "button";
|
|
7969
|
+
topUp.disabled = !subscribed;
|
|
7970
|
+
if (!subscribed) topUp.title = t("paywall.needsPlan");
|
|
7971
|
+
topUp.addEventListener("click", () => {
|
|
7972
|
+
closeRowMenu();
|
|
7973
|
+
openPaywall(true);
|
|
7974
|
+
});
|
|
7975
|
+
actions.appendChild(topUp);
|
|
7976
|
+
head.appendChild(actions);
|
|
7977
|
+
}
|
|
7978
|
+
items.push(head);
|
|
6925
7979
|
}
|
|
7980
|
+
// The dashboard straight under the balance it explains, and above the
|
|
7981
|
+
// workspace list, which is a choice rather than a destination.
|
|
6926
7982
|
items.push(
|
|
6927
7983
|
menuItem(t("account.dashboard"), {
|
|
6928
7984
|
run: () => {
|
|
6929
7985
|
closeRowMenu();
|
|
6930
|
-
// window.open, so the desktop build hands this to the real browser
|
|
6931
|
-
// rather than loading the site inside the window the agent runs in.
|
|
6932
7986
|
if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
|
|
6933
7987
|
},
|
|
6934
7988
|
}),
|
|
7989
|
+
);
|
|
7990
|
+
// Whose credits the next call spends: Personal, or a team the account is
|
|
7991
|
+
// on, the current one ticked the way the model menu ticks its choice.
|
|
7992
|
+
// Only once there is a team to choose, since a list of one is no choice.
|
|
7993
|
+
if (knownTeams.length) {
|
|
7994
|
+
items.push(el("div", "menu-head menu-section", t("account.workspace")));
|
|
7995
|
+
const choice = (label, id) => {
|
|
7996
|
+
const chosen = (body.team || null) === id;
|
|
7997
|
+
const item = el("button", "menu-item" + (chosen ? " on" : ""));
|
|
7998
|
+
item.type = "button";
|
|
7999
|
+
item.appendChild(el("span", "tick", "\u2713"));
|
|
8000
|
+
item.appendChild(el("span", "menu-label", label));
|
|
8001
|
+
item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
|
|
8002
|
+
return item;
|
|
8003
|
+
};
|
|
8004
|
+
items.push(choice(t("account.personal"), null));
|
|
8005
|
+
knownTeams.forEach((team) => items.push(choice(team.name, team.id)));
|
|
8006
|
+
}
|
|
8007
|
+
// The way to a workspace at all, for everyone signed in: Create a team for an
|
|
8008
|
+
// account on none, Manage workspaces once there is one. Both are the team page
|
|
8009
|
+
// on the site, in the real browser, where a team is started, its people
|
|
8010
|
+
// invited and its seats bought.
|
|
8011
|
+
if (body.site) {
|
|
8012
|
+
items.push(
|
|
8013
|
+
menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
|
|
8014
|
+
run: () => {
|
|
8015
|
+
closeRowMenu();
|
|
8016
|
+
window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
8017
|
+
},
|
|
8018
|
+
}),
|
|
8019
|
+
);
|
|
8020
|
+
}
|
|
8021
|
+
items.push(
|
|
6935
8022
|
menuItem(t("account.download"), {
|
|
6936
8023
|
run: () => {
|
|
6937
8024
|
closeRowMenu();
|
|
@@ -6952,6 +8039,11 @@ async function loadAccount() {
|
|
|
6952
8039
|
}),
|
|
6953
8040
|
);
|
|
6954
8041
|
openRowMenu(row, row, items);
|
|
8042
|
+
// As wide as the row it opens from, and placed again once it is: the
|
|
8043
|
+
// first placement measured the menu at its own width.
|
|
8044
|
+
rowMenu.classList.add("fit-anchor", "account-menu");
|
|
8045
|
+
rowMenu.style.width = row.getBoundingClientRect().width + "px";
|
|
8046
|
+
placeRowMenu(row);
|
|
6955
8047
|
});
|
|
6956
8048
|
host.appendChild(row);
|
|
6957
8049
|
}
|
|
@@ -7238,6 +8330,48 @@ function openPaywall(buying) {
|
|
|
7238
8330
|
return;
|
|
7239
8331
|
}
|
|
7240
8332
|
|
|
8333
|
+
// A team is chosen, so the balance that ran out is the team's, and the
|
|
8334
|
+
// shop for it is on the site rather than here — only someone with billing
|
|
8335
|
+
// permission buys for the team, and they do it on the team page. What this screen
|
|
8336
|
+
// can offer is the team's number, who can fix it, and the way back to
|
|
8337
|
+
// spending one's own credits.
|
|
8338
|
+
if (state.team) {
|
|
8339
|
+
const teamName = (knownTeams.find((team) => team.id === state.team.id) || {}).name
|
|
8340
|
+
|| t("account.teamUnnamed");
|
|
8341
|
+
if (state.team.refused) {
|
|
8342
|
+
body.appendChild(el("div", "cn-note", t("account.teamRefused", { team: teamName })));
|
|
8343
|
+
} else {
|
|
8344
|
+
const pooled = el("p", "pw-balance");
|
|
8345
|
+
pooled.appendChild(el("strong", null, creditsOf(state.team.balanceMicro || 0)));
|
|
8346
|
+
pooled.appendChild(el("span", "muted", t("paywall.teamUnit", { team: teamName })));
|
|
8347
|
+
body.appendChild(pooled);
|
|
8348
|
+
body.appendChild(
|
|
8349
|
+
el("div", "cn-note",
|
|
8350
|
+
t(state.team.billing ? "paywall.teamOwner" : "paywall.teamMember", { team: teamName })),
|
|
8351
|
+
);
|
|
8352
|
+
}
|
|
8353
|
+
const actions = el("div", "pw-plans");
|
|
8354
|
+
if (state.team.billing || state.team.refused) {
|
|
8355
|
+
const page = el("button", "pw-plan", t("paywall.teamPage"));
|
|
8356
|
+
page.type = "button";
|
|
8357
|
+
page.onclick = () => {
|
|
8358
|
+
window.open((site || "https://oharness.ai") + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
8359
|
+
};
|
|
8360
|
+
actions.appendChild(page);
|
|
8361
|
+
}
|
|
8362
|
+
const own = el("button", "pw-plan", t("account.useOwn"));
|
|
8363
|
+
own.type = "button";
|
|
8364
|
+
own.onclick = () => {
|
|
8365
|
+
void api("/team", { team: null }).then(() => {
|
|
8366
|
+
void loadAccount();
|
|
8367
|
+
void check();
|
|
8368
|
+
});
|
|
8369
|
+
};
|
|
8370
|
+
actions.appendChild(own);
|
|
8371
|
+
body.appendChild(actions);
|
|
8372
|
+
return;
|
|
8373
|
+
}
|
|
8374
|
+
|
|
7241
8375
|
const sub = state.subscription;
|
|
7242
8376
|
const subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
|
|
7243
8377
|
body.appendChild(
|
|
@@ -7249,7 +8383,9 @@ function openPaywall(buying) {
|
|
|
7249
8383
|
// checkout for it — so it is not a row. The account page button below is
|
|
7250
8384
|
// where it lives.
|
|
7251
8385
|
const packs = plans.filter((plan) => plan.kind === "topup");
|
|
7252
|
-
|
|
8386
|
+
// The seat plan is a team's and is bought on the team page; on this
|
|
8387
|
+
// list it would be a rung this account cannot buy for itself.
|
|
8388
|
+
const subs = plans.filter((plan) => plan.kind === "subscription" && !plan.seat);
|
|
7253
8389
|
|
|
7254
8390
|
if (!subs.length && !packs.length) {
|
|
7255
8391
|
body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
|
|
@@ -7407,10 +8543,14 @@ function openPaywall(buying) {
|
|
|
7407
8543
|
}
|
|
7408
8544
|
// Credits on the account mean the turn can be sent again, whether they
|
|
7409
8545
|
// arrived a second ago or were there the whole time. Both are the same
|
|
7410
|
-
// sentence to whoever is looking at this.
|
|
7411
|
-
|
|
8546
|
+
// sentence to whoever is looking at this. With a team chosen it is the
|
|
8547
|
+
// team's number that decides: the person's own credits are not what
|
|
8548
|
+
// the next call draws on.
|
|
8549
|
+
const pooled = state.team && !state.team.refused ? state.team : undefined;
|
|
8550
|
+
const spendable = pooled ? Number(pooled.balanceMicro || 0) : Number(state.balanceMicro || 0);
|
|
8551
|
+
if (state.signedIn && spendable > 0) {
|
|
7412
8552
|
closePaywall();
|
|
7413
|
-
return void addNotice(t("paywall.ready", { credits: creditsOf(
|
|
8553
|
+
return void addNotice(t("paywall.ready", { credits: creditsOf(spendable) }));
|
|
7414
8554
|
}
|
|
7415
8555
|
render(state);
|
|
7416
8556
|
});
|
|
@@ -9391,6 +10531,7 @@ void (async () => {
|
|
|
9391
10531
|
for (const button of document.querySelectorAll("#starters button")) {
|
|
9392
10532
|
button.addEventListener("click", () => {
|
|
9393
10533
|
input.value = button.dataset.p;
|
|
10534
|
+
paintMirror();
|
|
9394
10535
|
document.getElementById("composer").requestSubmit();
|
|
9395
10536
|
});
|
|
9396
10537
|
}
|
|
@@ -9420,7 +10561,7 @@ export const PAGE = `<!doctype html>
|
|
|
9420
10561
|
<html lang="en">
|
|
9421
10562
|
<head>
|
|
9422
10563
|
<meta charset="utf-8">
|
|
9423
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
10564
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
|
|
9424
10565
|
<meta name="referrer" content="no-referrer">
|
|
9425
10566
|
<title>OHarness</title>
|
|
9426
10567
|
<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON)}">
|
|
@@ -9439,6 +10580,7 @@ ${ANALYTICS_STUB}
|
|
|
9439
10580
|
</svg>
|
|
9440
10581
|
</span>
|
|
9441
10582
|
<b class="wordmark">OHarness</b>
|
|
10583
|
+
<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>
|
|
9442
10584
|
</header>
|
|
9443
10585
|
<button id="project" class="project" type="button">
|
|
9444
10586
|
<span class="avatar" id="project-avatar"></span>
|
|
@@ -9475,6 +10617,7 @@ ${ANALYTICS_STUB}
|
|
|
9475
10617
|
model-provider setup — a place nobody goes to find their account. -->
|
|
9476
10618
|
<div id="account"></div>
|
|
9477
10619
|
</aside>
|
|
10620
|
+
<div id="scrim"></div>
|
|
9478
10621
|
|
|
9479
10622
|
<main>
|
|
9480
10623
|
<div id="top">
|
|
@@ -9517,7 +10660,14 @@ ${ANALYTICS_STUB}
|
|
|
9517
10660
|
<form id="composer">
|
|
9518
10661
|
<div class="composer" id="composer-box">
|
|
9519
10662
|
<div id="attachments"></div>
|
|
9520
|
-
|
|
10663
|
+
<!-- A textarea draws its own glyphs and cannot style a range of them, so
|
|
10664
|
+
the chip behind a mention is painted on a layer underneath that
|
|
10665
|
+
mirrors the same text in the same metrics. aria-hidden: it is the
|
|
10666
|
+
same string twice, and a screen reader should hear it once. -->
|
|
10667
|
+
<div class="input-wrap">
|
|
10668
|
+
<div id="input-mirror" aria-hidden="true"></div>
|
|
10669
|
+
<textarea id="input" rows="1" data-t-placeholder="composer.placeholder"></textarea>
|
|
10670
|
+
</div>
|
|
9521
10671
|
<div class="composer-bar">
|
|
9522
10672
|
<div class="picker" id="attach-picker">
|
|
9523
10673
|
<button id="attach" class="icon-btn" type="button" data-t-title="composer.attach"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3.2v9.6M3.2 8h9.6"/></svg></button>
|
|
@@ -9566,6 +10716,7 @@ ${ANALYTICS_STUB}
|
|
|
9566
10716
|
<div id="review-head">
|
|
9567
10717
|
<span id="review-title" data-t="review.changes"></span>
|
|
9568
10718
|
<span id="review-sum"></span>
|
|
10719
|
+
<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>
|
|
9569
10720
|
</div>
|
|
9570
10721
|
<div id="files"></div>
|
|
9571
10722
|
<div id="diff"></div>
|
|
@@ -9604,9 +10755,10 @@ ${ANALYTICS_STUB}
|
|
|
9604
10755
|
<div class="mk-head">
|
|
9605
10756
|
<div id="mk-title" class="cn-title"></div>
|
|
9606
10757
|
<div id="mk-note" class="row-desc"></div>
|
|
10758
|
+
<input id="mk-search" class="mk-search" type="text" autocomplete="off" spellcheck="false">
|
|
10759
|
+
<button id="mk-close" class="mk-close" type="button"></button>
|
|
9607
10760
|
</div>
|
|
9608
10761
|
<div id="mk-list"></div>
|
|
9609
|
-
<div class="mk-foot"><button id="mk-close" class="ghost" type="button" data-t="market.close"></button></div>
|
|
9610
10762
|
</dialog>
|
|
9611
10763
|
|
|
9612
10764
|
<dialog id="provider-picker">
|