@oharness/server 0.4.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 +32 -0
- package/dist/i18n.js.map +1 -1
- package/dist/page.js +485 -57
- package/dist/page.js.map +1 -1
- package/dist/server.js +83 -3
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/page.js
CHANGED
|
@@ -205,6 +205,14 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
205
205
|
}
|
|
206
206
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
207
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
|
+
}
|
|
208
216
|
/*
|
|
209
217
|
* Destructive is carried by the label's colour, not by a filled red bar. A
|
|
210
218
|
* solid block reads as an alert that has already happened rather than as a
|
|
@@ -223,8 +231,24 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
223
231
|
.row-menu .menu-head{
|
|
224
232
|
padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
|
|
225
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
|
+
}
|
|
226
240
|
.menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
|
|
227
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}
|
|
228
252
|
/* One entry carrying the accent, for the one that is an offer rather than a
|
|
229
253
|
destination. var(--field) was too close to the menu's own surface to read as
|
|
230
254
|
anything in the light theme — a highlight nobody can see is worse than none,
|
|
@@ -411,6 +435,11 @@ svg.vglyph{width:15px;height:15px;opacity:1;
|
|
|
411
435
|
the composer bar's 2px does not provide. */
|
|
412
436
|
.menu-group .vmark,.menu-group .vmono,.menu-group .vglyph{width:13px;height:13px;font-size:8px}
|
|
413
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}
|
|
414
443
|
/* On a card the row is a title over a line of description, so the mark is
|
|
415
444
|
sized against the block rather than against the type, and sits on the
|
|
416
445
|
title's line instead of floating in the middle of the two. */
|
|
@@ -492,8 +521,18 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
|
492
521
|
* Tabs are sessions; the drawer beside them shows one artifact whichever tab
|
|
493
522
|
* is in front. Spanning both left a band of empty chrome above the drawer that
|
|
494
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.
|
|
495
531
|
*/
|
|
496
|
-
#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
|
+
}
|
|
497
536
|
#tabs:empty{display:none}
|
|
498
537
|
.tab{
|
|
499
538
|
display:flex;align-items:center;gap:7px;max-width:190px;height:30px;padding:0 6px 0 10px;
|
|
@@ -501,7 +540,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
|
501
540
|
font:inherit;font-size:12.5px;cursor:pointer;flex:0 0 auto;
|
|
502
541
|
}
|
|
503
542
|
.tab:hover{background:var(--hover);color:var(--fg)}
|
|
504
|
-
.tab.on{background:var(--
|
|
543
|
+
.tab.on{background:var(--bg);color:var(--fg)}
|
|
505
544
|
.tab .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
506
545
|
/* Which project a tab is in, shown only when the open tabs span more than one:
|
|
507
546
|
two transcripts called "Untitled" in different trees are otherwise the same
|
|
@@ -562,7 +601,7 @@ body.panelled #composer,body.panelled #jump,body.panelled #changes-btn{display:n
|
|
|
562
601
|
border-left:2px solid transparent;
|
|
563
602
|
}
|
|
564
603
|
.file:hover{background:var(--bg)}
|
|
565
|
-
.file.active{background:var(--
|
|
604
|
+
.file.active{background:var(--sel)}
|
|
566
605
|
.file .p{font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
|
567
606
|
.file .n{font-size:11px;font-family:ui-monospace,monospace}
|
|
568
607
|
.file .plus{color:var(--ok)} .file .minus{color:var(--err)}
|
|
@@ -638,7 +677,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
638
677
|
/* Disabled means "not yet", not "never": it stays legible, and the row says
|
|
639
678
|
why. Hiding it instead left people looking for a control that exists. */
|
|
640
679
|
.switch:disabled{opacity:.45;cursor:default}
|
|
641
|
-
.switch:focus-visible{outline:
|
|
680
|
+
.switch:focus-visible{outline:none;filter:brightness(.9)}
|
|
642
681
|
.share-btn{
|
|
643
682
|
border:1px solid var(--line);background:var(--field);color:var(--fg);
|
|
644
683
|
border-radius:5px;padding:2px 8px;font:inherit;font-size:11px;cursor:pointer;
|
|
@@ -676,7 +715,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
676
715
|
transcript look like a dialog, and next to the tool cards it sits between —
|
|
677
716
|
which have none — it was the only thing drawing a box around itself. */
|
|
678
717
|
.art-card:hover{background:var(--sel)}
|
|
679
|
-
.art-card:focus-visible{outline:
|
|
718
|
+
.art-card:focus-visible{outline:none;background:var(--sel)}
|
|
680
719
|
.art-card-mark{
|
|
681
720
|
display:flex;align-items:center;justify-content:center;flex:0 0 auto;
|
|
682
721
|
width:28px;height:28px;border-radius:7px;background:var(--field);color:var(--fg);
|
|
@@ -798,10 +837,10 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
798
837
|
/* Filled, not outlined — the same shape .art-search uses. A box drawn with a
|
|
799
838
|
* line around it is the one thing this site does not do, and three of them
|
|
800
839
|
* stacked made the editor look like a form bolted onto the page rather than
|
|
801
|
-
* part of the note.
|
|
802
|
-
*
|
|
840
|
+
* part of the note. Focus deepens the fill rather than drawing a ring: no
|
|
841
|
+
* border marks a focused thing anywhere here. */
|
|
803
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}
|
|
804
|
-
.memory-panel input:focus,.memory-panel textarea:focus{outline:
|
|
843
|
+
.memory-panel input:focus,.memory-panel textarea:focus{outline:none;background:var(--field-focus)}
|
|
805
844
|
.memory-panel textarea{resize:vertical;min-height:130px}
|
|
806
845
|
.memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
|
|
807
846
|
.memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
|
|
@@ -845,7 +884,7 @@ details.setting[open]>summary{border-radius:10px 10px 0 0}
|
|
|
845
884
|
details.setting[open]>summary .chev{transform:rotate(90deg)}
|
|
846
885
|
details.setting>summary .ghost{flex:0 0 auto;height:26px;font-size:12px;margin-left:0}
|
|
847
886
|
details.setting>summary .switch{flex:0 0 auto}
|
|
848
|
-
details.setting>summary:focus-visible{outline:
|
|
887
|
+
details.setting>summary:focus-visible{outline:none;background:var(--hover)}
|
|
849
888
|
/*
|
|
850
889
|
* The artifacts panel: a search field over the whole catalogue.
|
|
851
890
|
*
|
|
@@ -1001,10 +1040,20 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
1001
1040
|
separate the account from the list — a hairline as well is a second answer
|
|
1002
1041
|
to a question the whitespace had settled, and it is the only line left in a
|
|
1003
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. */
|
|
1004
1047
|
#account{
|
|
1005
|
-
display:flex;align-items:
|
|
1048
|
+
display:flex;flex-direction:column;align-items:stretch;gap:2px;
|
|
1006
1049
|
flex:0 0 auto;padding:8px;
|
|
1007
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}
|
|
1008
1057
|
/* Signed out this holds one nav row and nothing else, so the padding that
|
|
1009
1058
|
frames an account goes away and the row sits flush with the ones above it.
|
|
1010
1059
|
8px across, matching the nav list above, so the icon lands on the same
|
|
@@ -1015,7 +1064,8 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
1015
1064
|
taller than the nav rows above: a footer is allowed to be the roomiest row
|
|
1016
1065
|
in the sidebar, and this one has to hold a click aimed at from the corner. */
|
|
1017
1066
|
#account.out{padding:4px 8px 10px}
|
|
1018
|
-
#account
|
|
1067
|
+
#account .nav-item{width:100%}
|
|
1068
|
+
#account.out .nav-item{height:34px}
|
|
1019
1069
|
/* The whole row is the control. It used to be a label with a Sign out button
|
|
1020
1070
|
parked beside it, which gave the rarest action in the sidebar the most
|
|
1021
1071
|
reliable target in it, and left the name — the thing worth clicking, because
|
|
@@ -1083,15 +1133,15 @@ button.signin-site:disabled{opacity:.4;cursor:default}
|
|
|
1083
1133
|
* The site's pricing page settled this and says why in its own stylesheet: the
|
|
1084
1134
|
* offers should read as columns of type rather than as a row of frames, and on
|
|
1085
1135
|
* a screen that is mostly numbers the frames are the loudest thing on it. So
|
|
1086
|
-
* the fill goes quiet and the recommended rung
|
|
1087
|
-
*
|
|
1136
|
+
* the fill goes quiet and the recommended rung takes a deeper fill. No outline
|
|
1137
|
+
* marks anything here, the recommended rung included.
|
|
1088
1138
|
*/
|
|
1089
1139
|
button.pw-plan{
|
|
1090
1140
|
display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
|
|
1091
1141
|
min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
|
|
1092
1142
|
background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
|
|
1093
1143
|
}
|
|
1094
|
-
button.pw-plan.featured{
|
|
1144
|
+
button.pw-plan.featured{background:var(--sel)}
|
|
1095
1145
|
.pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
1096
1146
|
button.pw-plan:hover{background:var(--sel)}
|
|
1097
1147
|
button.pw-plan.featured:hover{background:var(--hover)}
|
|
@@ -1612,7 +1662,7 @@ textarea.boxed{
|
|
|
1612
1662
|
padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
|
|
1613
1663
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
1614
1664
|
}
|
|
1615
|
-
textarea.boxed:focus{
|
|
1665
|
+
textarea.boxed:focus{outline:none;background:var(--field-focus)}
|
|
1616
1666
|
.composer-bar{display:flex;align-items:center;gap:6px;margin-top:10px}
|
|
1617
1667
|
.composer.drag{background:var(--field-focus);outline:2px dashed var(--line);outline-offset:-6px}
|
|
1618
1668
|
.icon-btn{
|
|
@@ -1694,6 +1744,13 @@ textarea.boxed:focus{border-color:var(--dim)}
|
|
|
1694
1744
|
the arrow keys. A ring around a popup that was just clicked open says
|
|
1695
1745
|
nothing the row highlight is not already saying. */
|
|
1696
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)}
|
|
1697
1754
|
/* The triangle the group headers use, turned on a row that leads somewhere:
|
|
1698
1755
|
the + opens onto four offers, and three of them go one screen deeper. */
|
|
1699
1756
|
.menu-item .chev{
|
|
@@ -1805,14 +1862,134 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
|
|
|
1805
1862
|
padding:8px 18px 16px;font-size:11.5px;line-height:1.6;color:var(--dim);min-height:34px;
|
|
1806
1863
|
}
|
|
1807
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
|
+
*/
|
|
1808
1897
|
@media (max-width:760px){
|
|
1809
|
-
aside{
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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}
|
|
1814
1992
|
}
|
|
1815
|
-
#burger{display:none;background:none;border:0;color:var(--fg);font:inherit;cursor:pointer;font-size:18px}
|
|
1816
1993
|
`;
|
|
1817
1994
|
const APP_SCRIPT = String.raw `
|
|
1818
1995
|
const STRINGS = window.__STRINGS;
|
|
@@ -1838,6 +2015,10 @@ if (window.oharness && window.oharness.insetTitlebar) {
|
|
|
1838
2015
|
/** Whether this page is the desktop window rather than a browser tab. */
|
|
1839
2016
|
const IS_DESKTOP = Boolean(window.oharness && window.oharness.desktop);
|
|
1840
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
|
+
|
|
1841
2022
|
// --- language --------------------------------------------------------------
|
|
1842
2023
|
|
|
1843
2024
|
/**
|
|
@@ -1944,6 +2125,31 @@ const ICON_PATHS = {
|
|
|
1944
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" +
|
|
1945
2126
|
"A1.33 1.33 0 0 1 14 3.33Z",
|
|
1946
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]],
|
|
1947
2153
|
};
|
|
1948
2154
|
|
|
1949
2155
|
function svgIcon(name, extra) {
|
|
@@ -2468,12 +2674,16 @@ function makePicker(id, onPick, extra) {
|
|
|
2468
2674
|
return api;
|
|
2469
2675
|
}
|
|
2470
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
|
+
|
|
2471
2681
|
const MODES = [
|
|
2472
|
-
{ value: "default", label: "Manual", sub: "ask first" },
|
|
2473
|
-
{ value: "acceptEdits", label: "Accept edits", sub: "edits pass" },
|
|
2474
|
-
{ value: "plan", label: "Plan", sub: "read-only" },
|
|
2475
|
-
{ value: "auto", label: "Auto", sub: "routine passes" },
|
|
2476
|
-
{ value: "bypassPermissions", label: "Bypass", sub: "all pass" },
|
|
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") },
|
|
2477
2687
|
];
|
|
2478
2688
|
|
|
2479
2689
|
const modelPicker = makePicker(
|
|
@@ -3493,6 +3703,8 @@ function closeRowMenu() {
|
|
|
3493
3703
|
menuRow = null;
|
|
3494
3704
|
rowMenu.hidden = true;
|
|
3495
3705
|
rowMenu.replaceChildren();
|
|
3706
|
+
rowMenu.classList.remove("fit-anchor", "account-menu");
|
|
3707
|
+
rowMenu.style.width = "";
|
|
3496
3708
|
}
|
|
3497
3709
|
|
|
3498
3710
|
/**
|
|
@@ -3505,8 +3717,9 @@ function placeRowMenu(button) {
|
|
|
3505
3717
|
const anchor = button.getBoundingClientRect();
|
|
3506
3718
|
const box = rowMenu.getBoundingClientRect();
|
|
3507
3719
|
const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
|
|
3508
|
-
const
|
|
3509
|
-
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;
|
|
3510
3723
|
rowMenu.style.left = Math.max(8, left) + "px";
|
|
3511
3724
|
rowMenu.style.top = Math.max(8, top) + "px";
|
|
3512
3725
|
}
|
|
@@ -4176,7 +4389,9 @@ events.onmessage = (message) => {
|
|
|
4176
4389
|
if (wasSelected) void showArtifact(event.artifact.id);
|
|
4177
4390
|
// First artifact of the session opens the drawer, the way the first file
|
|
4178
4391
|
// change makes the review button appear. Later ones do not steal focus.
|
|
4179
|
-
|
|
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");
|
|
4180
4395
|
break;
|
|
4181
4396
|
}
|
|
4182
4397
|
|
|
@@ -4721,7 +4936,7 @@ function attachRows() {
|
|
|
4721
4936
|
// draws; a plug in front of three names is three rows told apart by
|
|
4722
4937
|
// reading.
|
|
4723
4938
|
mark: kind === "connectors" ? entry.name : undefined,
|
|
4724
|
-
label: entry.name,
|
|
4939
|
+
label: entry.title || entry.name,
|
|
4725
4940
|
note: entry.description || "",
|
|
4726
4941
|
ref: {
|
|
4727
4942
|
kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
|
|
@@ -4828,13 +5043,14 @@ function attachRows() {
|
|
|
4828
5043
|
.filter((plugin) =>
|
|
4829
5044
|
!query ||
|
|
4830
5045
|
plugin.name.toLowerCase().includes(query) ||
|
|
5046
|
+
(plugin.title || "").toLowerCase().includes(query) ||
|
|
4831
5047
|
(plugin.description || "").toLowerCase().includes(query),
|
|
4832
5048
|
)
|
|
4833
5049
|
.slice(0, 4)
|
|
4834
5050
|
.map((plugin) => ({
|
|
4835
5051
|
type: "plugin",
|
|
4836
5052
|
icon: "plugin",
|
|
4837
|
-
label: plugin.name,
|
|
5053
|
+
label: plugin.title || plugin.name,
|
|
4838
5054
|
note: plugin.description || "",
|
|
4839
5055
|
ref: { kind: "plugin", value: plugin.name, label: plugin.name },
|
|
4840
5056
|
}));
|
|
@@ -5140,6 +5356,7 @@ function paintMarket() {
|
|
|
5140
5356
|
const hit = (entry) =>
|
|
5141
5357
|
!query ||
|
|
5142
5358
|
(entry.name || "").toLowerCase().includes(query) ||
|
|
5359
|
+
(entry.title || "").toLowerCase().includes(query) ||
|
|
5143
5360
|
(entry.description || "").toLowerCase().includes(query) ||
|
|
5144
5361
|
(entry.label || "").toLowerCase().includes(query);
|
|
5145
5362
|
const installed = (
|
|
@@ -5256,7 +5473,9 @@ function installedRow(entry) {
|
|
|
5256
5473
|
row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
|
|
5257
5474
|
|
|
5258
5475
|
const naming = el("div", "mk-naming");
|
|
5259
|
-
|
|
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);
|
|
5260
5479
|
/**
|
|
5261
5480
|
* Where it came from, when that is not "this machine".
|
|
5262
5481
|
*
|
|
@@ -5718,7 +5937,8 @@ function openCommands(command) {
|
|
|
5718
5937
|
attachPicker.classList.add("open");
|
|
5719
5938
|
const bar = attachPicker.getBoundingClientRect().top;
|
|
5720
5939
|
const box = composerBox.getBoundingClientRect().top;
|
|
5721
|
-
|
|
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)";
|
|
5722
5942
|
paintAttachMenu();
|
|
5723
5943
|
}
|
|
5724
5944
|
|
|
@@ -5775,7 +5995,8 @@ function openMention(mention) {
|
|
|
5775
5995
|
// the sentence it is going into.
|
|
5776
5996
|
const bar = attachPicker.getBoundingClientRect().top;
|
|
5777
5997
|
const box = composerBox.getBoundingClientRect().top;
|
|
5778
|
-
|
|
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)";
|
|
5779
6000
|
paintAttachMenu();
|
|
5780
6001
|
searchMentionFiles();
|
|
5781
6002
|
}
|
|
@@ -5924,8 +6145,50 @@ stopBtn.addEventListener("click", () => void api("/interrupt", { session: active
|
|
|
5924
6145
|
// almost every time, and a dialog in front of every new session charges the
|
|
5925
6146
|
// common case for the rare one.
|
|
5926
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
|
+
}
|
|
5927
6163
|
document.getElementById("burger").addEventListener("click", () =>
|
|
5928
|
-
|
|
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
|
+
}
|
|
5929
6192
|
|
|
5930
6193
|
|
|
5931
6194
|
// --- review pane (the Codex-shaped part) ----------------------------------
|
|
@@ -7498,6 +7761,31 @@ function discoverNow(providerId) {
|
|
|
7498
7761
|
});
|
|
7499
7762
|
}
|
|
7500
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
|
+
|
|
7501
7789
|
/** A person, drawn to match the sidebar's other 16px line icons. */
|
|
7502
7790
|
function iconPerson() {
|
|
7503
7791
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
@@ -7535,6 +7823,15 @@ function iconPerson() {
|
|
|
7535
7823
|
* and none of those can be hosted in a local window — they keep working here
|
|
7536
7824
|
* precisely because this does not reimplement them.
|
|
7537
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
|
+
|
|
7538
7835
|
async function loadAccount() {
|
|
7539
7836
|
const host = document.getElementById("account");
|
|
7540
7837
|
if (!host) return;
|
|
@@ -7599,6 +7896,18 @@ async function loadAccount() {
|
|
|
7599
7896
|
// Whether there is a shop at all. A self-hosted gateway sells nothing, and
|
|
7600
7897
|
// an entry that opens a paywall with no plans in it is worse than no entry.
|
|
7601
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
|
+
};
|
|
7602
7911
|
void getJson("/billing").then((state) => {
|
|
7603
7912
|
if (!state || !state.signedIn || state.unavailable) return;
|
|
7604
7913
|
sellsCredits = true;
|
|
@@ -7607,8 +7916,28 @@ async function loadAccount() {
|
|
|
7607
7916
|
}
|
|
7608
7917
|
const sub = state.subscription;
|
|
7609
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
|
+
}
|
|
7610
7928
|
});
|
|
7611
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
|
+
|
|
7612
7941
|
row.addEventListener("click", (e) => {
|
|
7613
7942
|
e.stopPropagation();
|
|
7614
7943
|
if (menuRow === row) return closeRowMenu();
|
|
@@ -7617,36 +7946,79 @@ async function loadAccount() {
|
|
|
7617
7946
|
// dim lines the size of a footnote, above the things to click — which is
|
|
7618
7947
|
// backwards: the balance is why somebody opened this menu, and the plan
|
|
7619
7948
|
// is what decides which of the entries below is the one they want.
|
|
7620
|
-
if (balance || plan) {
|
|
7949
|
+
if (balance || plan || teamLine) {
|
|
7621
7950
|
const head = el("div", "menu-head");
|
|
7622
7951
|
if (balance) head.appendChild(el("div", "menu-head-main", balance));
|
|
7623
7952
|
if (plan) head.appendChild(el("div", "menu-head-sub", plan));
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
menuItem(t("account.getCredits"), {
|
|
7634
|
-
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", () => {
|
|
7635
7962
|
closeRowMenu();
|
|
7636
7963
|
openPaywall(true);
|
|
7637
|
-
}
|
|
7638
|
-
|
|
7639
|
-
|
|
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);
|
|
7640
7979
|
}
|
|
7980
|
+
// The dashboard straight under the balance it explains, and above the
|
|
7981
|
+
// workspace list, which is a choice rather than a destination.
|
|
7641
7982
|
items.push(
|
|
7642
7983
|
menuItem(t("account.dashboard"), {
|
|
7643
7984
|
run: () => {
|
|
7644
7985
|
closeRowMenu();
|
|
7645
|
-
// window.open, so the desktop build hands this to the real browser
|
|
7646
|
-
// rather than loading the site inside the window the agent runs in.
|
|
7647
7986
|
if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
|
|
7648
7987
|
},
|
|
7649
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(
|
|
7650
8022
|
menuItem(t("account.download"), {
|
|
7651
8023
|
run: () => {
|
|
7652
8024
|
closeRowMenu();
|
|
@@ -7667,6 +8039,11 @@ async function loadAccount() {
|
|
|
7667
8039
|
}),
|
|
7668
8040
|
);
|
|
7669
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);
|
|
7670
8047
|
});
|
|
7671
8048
|
host.appendChild(row);
|
|
7672
8049
|
}
|
|
@@ -7953,6 +8330,48 @@ function openPaywall(buying) {
|
|
|
7953
8330
|
return;
|
|
7954
8331
|
}
|
|
7955
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
|
+
|
|
7956
8375
|
const sub = state.subscription;
|
|
7957
8376
|
const subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
|
|
7958
8377
|
body.appendChild(
|
|
@@ -7964,7 +8383,9 @@ function openPaywall(buying) {
|
|
|
7964
8383
|
// checkout for it — so it is not a row. The account page button below is
|
|
7965
8384
|
// where it lives.
|
|
7966
8385
|
const packs = plans.filter((plan) => plan.kind === "topup");
|
|
7967
|
-
|
|
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);
|
|
7968
8389
|
|
|
7969
8390
|
if (!subs.length && !packs.length) {
|
|
7970
8391
|
body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
|
|
@@ -8122,10 +8543,14 @@ function openPaywall(buying) {
|
|
|
8122
8543
|
}
|
|
8123
8544
|
// Credits on the account mean the turn can be sent again, whether they
|
|
8124
8545
|
// arrived a second ago or were there the whole time. Both are the same
|
|
8125
|
-
// sentence to whoever is looking at this.
|
|
8126
|
-
|
|
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) {
|
|
8127
8552
|
closePaywall();
|
|
8128
|
-
return void addNotice(t("paywall.ready", { credits: creditsOf(
|
|
8553
|
+
return void addNotice(t("paywall.ready", { credits: creditsOf(spendable) }));
|
|
8129
8554
|
}
|
|
8130
8555
|
render(state);
|
|
8131
8556
|
});
|
|
@@ -10136,7 +10561,7 @@ export const PAGE = `<!doctype html>
|
|
|
10136
10561
|
<html lang="en">
|
|
10137
10562
|
<head>
|
|
10138
10563
|
<meta charset="utf-8">
|
|
10139
|
-
<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">
|
|
10140
10565
|
<meta name="referrer" content="no-referrer">
|
|
10141
10566
|
<title>OHarness</title>
|
|
10142
10567
|
<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON)}">
|
|
@@ -10155,6 +10580,7 @@ ${ANALYTICS_STUB}
|
|
|
10155
10580
|
</svg>
|
|
10156
10581
|
</span>
|
|
10157
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>
|
|
10158
10584
|
</header>
|
|
10159
10585
|
<button id="project" class="project" type="button">
|
|
10160
10586
|
<span class="avatar" id="project-avatar"></span>
|
|
@@ -10191,6 +10617,7 @@ ${ANALYTICS_STUB}
|
|
|
10191
10617
|
model-provider setup — a place nobody goes to find their account. -->
|
|
10192
10618
|
<div id="account"></div>
|
|
10193
10619
|
</aside>
|
|
10620
|
+
<div id="scrim"></div>
|
|
10194
10621
|
|
|
10195
10622
|
<main>
|
|
10196
10623
|
<div id="top">
|
|
@@ -10289,6 +10716,7 @@ ${ANALYTICS_STUB}
|
|
|
10289
10716
|
<div id="review-head">
|
|
10290
10717
|
<span id="review-title" data-t="review.changes"></span>
|
|
10291
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>
|
|
10292
10720
|
</div>
|
|
10293
10721
|
<div id="files"></div>
|
|
10294
10722
|
<div id="diff"></div>
|