@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/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;align-self:flex-start;margin-top:1px;
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{display:flex;align-items:center;gap:2px;padding:0 12px 2px;overflow-x:auto;flex:0 0 auto}
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(--panel);color:var(--fg)}
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(--bg);border-left-color:var(--accent)}
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:2px solid var(--accent);outline-offset:2px}
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:2px solid var(--accent);outline-offset:1px}
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. The focus ring stays: that is the one border worth having,
773
- * and it appears only while the field is being used. */
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:2px solid var(--ring)}
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:2px solid var(--accent);outline-offset:-2px}
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
- details.provider>summary{
930
- padding:12px 14px;cursor:pointer;list-style:none;margin:0;gap:10px;
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:center;gap:8px;
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.out .nav-item{width:100%;height:34px}
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 keeps the one outline, which is
1049
- * what makes it a mark rather than a shade somebody has to notice.
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{border-color:var(--border-strong,var(--fg));background:var(--sel)}
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 6px;flex:1}
1121
- .mk-foot{padding:10px 16px 14px;display:flex;justify-content:flex-end;box-shadow:0 -1px 0 var(--line)}
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
- /* The rule above is the composer's: no border, no padding, transparent. A
1506
- textarea in a settings row needs to look like something you can type in. */
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{border-color:var(--dim)}
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{position:fixed;inset:0 auto 0 0;z-index:20;transform:translateX(-100%);transition:transform .2s}
1683
- aside.open{transform:none}
1684
- #burger{display:block}
1685
- /* The burger lives here, so the bar has a reason to exist again. */
1686
- #top{padding:10px 18px}
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 = full.length > 28 ? full.slice(full.indexOf("/") + 1) : full;
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 below = anchor.bottom + 4;
3348
- const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - 4 : below;
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
- if (artifacts.length === 1) shelf.classList.add("open");
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
- return (fileList.entries || []).map((entry) => ({
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: "market",
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: "market",
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(el("span", "tick", "✓"));
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 (attachView === "mention" && row.ref) return void insertMention(row.ref.value);
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
- document.getElementById("mk-title").textContent = t(
4748
- kind === "plugins" ? "add.plugins" : "add.connectors",
4749
- );
4750
- document.getElementById("mk-note").textContent = t(
4751
- kind === "plugins" ? "market.pluginsNote" : "market.connectorsNote",
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 installed = marketKind === "plugins" ? offer.plugins || [] : offer.connectors || [];
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
- row.appendChild(isPlugin ? svgIcon("plugin") : connectorMark(entry.name, entry.name));
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
- naming.appendChild(el("div", "mk-name", entry.name));
4877
- const detail = isPlugin
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
- if (!isPlugin) {
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 || attachMenu.hidden) return;
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
- attachCursor = (attachCursor + (e.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length;
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
- attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
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
- document.querySelector("aside").classList.toggle("open"));
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
- items.push(head);
6910
- }
6911
- // Buying, before the dashboard. Somebody who opened this menu after seeing
6912
- // "0 credits" two lines up wants the shop, and the dashboard is a longer
6913
- // way to the same place. It opens the paywall this app already has rather
6914
- // than a browser tab: the balance refreshes itself there, and the turn
6915
- // that could not be paid for is one click from being sent again.
6916
- if (sellsCredits) {
6917
- items.push(
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
- const subs = plans.filter((plan) => plan.kind === "subscription");
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
- if (state.signedIn && Number(state.balanceMicro || 0) > 0) {
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(state.balanceMicro) }));
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
- <textarea id="input" rows="1" data-t-placeholder="composer.placeholder"></textarea>
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">