@oharness/server 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/page.js CHANGED
@@ -96,6 +96,8 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
96
96
  .fold .ico-sm{width:16px;height:16px;flex:0 0 auto;opacity:.5}
97
97
  .fold.open .ico-sm,.fold:hover .ico-sm{opacity:.9}
98
98
  .fold-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
99
+ /* The routine group's run count, where a project header keeps its actions. */
100
+ .fold .count{flex:0 0 auto;font-size:11px;color:var(--dim);padding-right:4px}
99
101
  /* A gap between projects, but not above the first one. The selector names the
100
102
  row rather than the button since the header grew a menu control beside it. */
101
103
  .fold-row + .fold-row,.session + .fold-row,.show-more + .fold-row{margin-top:6px}
@@ -462,6 +464,18 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
462
464
  font-size:9.5px;letter-spacing:0;text-transform:none;padding:1px 5px;
463
465
  border-radius:4px;background:var(--accent);color:var(--bg);opacity:.85;
464
466
  }
467
+ /* The geometry .mk-tag established, which is what a badge looks like here.
468
+ Quieter than the accent a group header wears: a vendor is marked once at the
469
+ top of its list, while this can repeat down twenty rows, and twenty accent
470
+ pills is a column of noise. The two selected states have to move it off
471
+ var(--sel) — that is what the row under the cursor is painted with, and a
472
+ pill the same colour as the row it sits on is not a pill. */
473
+ .menu-item .m-tag{
474
+ flex:0 0 auto;padding:1px 5px;border-radius:4px;background:var(--sel);
475
+ color:var(--dim);font-size:10px;font-weight:500;letter-spacing:.02em;
476
+ text-transform:uppercase;
477
+ }
478
+ .menu-item:hover .m-tag,.menu-item.cursor .m-tag{background:var(--field)}
465
479
  .menu-more{
466
480
  width:100%;padding:8px;border:0;border-radius:7px;background:transparent;
467
481
  color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
@@ -824,8 +838,13 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
824
838
  .q-desc{font-size:12px;line-height:1.45;color:var(--dim);overflow-wrap:anywhere}
825
839
  .q-opt.on .q-desc{color:inherit;opacity:.75}
826
840
  .q-check{flex:none;width:14px;font-size:12px;line-height:20px}
827
- .q-other{cursor:text;align-items:center}
828
- .q-other.on{background:var(--sel)}
841
+ /* The typed answer is a field, so it keeps the plain row fill whether empty,
842
+ focused or filled; a filled one is marked by its key alone, which takes the
843
+ accent the picked options wear across the whole row. */
844
+ .q-other{cursor:text;align-items:center;padding-top:12px;padding-bottom:12px}
845
+ .q-other:focus-within,.q-other.on,.q-other.on:focus-visible{background:var(--bg);color:var(--fg);opacity:1}
846
+ .q-other.on .q-key{background:var(--accent);color:var(--bg)}
847
+ .q-other-input::placeholder{color:var(--dim)}
829
848
  .q-other-input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:13.5px;padding:0}
830
849
  .q-preview{
831
850
  margin:0;border-radius:9px;background:var(--bg);padding:10px 12px;font-size:12px;line-height:1.55;
@@ -1000,10 +1019,59 @@ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
1000
1019
  box, and the box's hairline goes with it — a list this long framed by a
1001
1020
  rule reads as a field somebody is meant to type in. */
1002
1021
  .audit-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
1003
- .panel.audit .audit-list{margin:6px 0 10px}
1004
- .panel.audit .audit-list .trail-line{padding:3px 0}
1022
+ .panel.audit .audit-list{margin:4px 0 12px}
1005
1023
  .setting-body .audit-list{
1006
- max-height:200px;overflow-y:auto;border-radius:6px;padding:7px 9px;background:var(--field);
1024
+ max-height:220px;overflow-y:auto;border-radius:8px;padding:4px;background:var(--field);
1025
+ }
1026
+ /* One grid for the whole trail, so time, outcome and tool line up down the
1027
+ page; each row is a subgrid of it so a row can still take a hover fill. */
1028
+ .audit-list{
1029
+ display:grid;grid-template-columns:max-content max-content minmax(0,1fr) max-content;
1030
+ column-gap:12px;font-size:12.5px;
1031
+ }
1032
+ .audit-row{
1033
+ display:grid;grid-column:1/-1;grid-template-columns:subgrid;align-items:center;
1034
+ padding:5px 8px;border-radius:7px;
1035
+ }
1036
+ .audit-row:hover{background:var(--hover)}
1037
+ .audit-row.denied{background:color-mix(in srgb,var(--err) 5%,transparent)}
1038
+ .audit-row.denied:hover{background:color-mix(in srgb,var(--err) 9%,transparent)}
1039
+ .audit-day{
1040
+ grid-column:1/-1;font-size:11.5px;font-weight:600;color:var(--dim);
1041
+ letter-spacing:.02em;padding:14px 8px 4px;
1042
+ }
1043
+ .audit-day:first-child{padding-top:2px}
1044
+ .audit-time{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:11.5px;color:var(--dim);font-variant-numeric:tabular-nums}
1045
+ .audit-pill{
1046
+ display:inline-flex;align-items:center;gap:5px;justify-self:start;
1047
+ font-size:11px;font-weight:600;line-height:1;padding:4px 8px 4px 7px;border-radius:999px;
1048
+ }
1049
+ .audit-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
1050
+ .audit-pill.allowed{color:var(--ok);background:color-mix(in srgb,var(--ok) 12%,transparent)}
1051
+ .audit-pill.denied{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
1052
+ .audit-pill.unanswered{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}
1053
+ .audit-what{display:flex;align-items:baseline;gap:6px;min-width:0;white-space:nowrap}
1054
+ .audit-tool{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-weight:600;color:var(--fg);flex:none}
1055
+ .audit-arg{
1056
+ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;color:var(--dim);
1057
+ overflow:hidden;text-overflow:ellipsis;min-width:0;
1058
+ }
1059
+ .audit-notes{display:flex;gap:4px;justify-self:end}
1060
+ .audit-tag{
1061
+ font-size:10.5px;color:var(--dim);background:var(--field);padding:2px 7px;border-radius:5px;white-space:nowrap;
1062
+ }
1063
+ .audit-tag.remembered{color:var(--brand-ink);background:var(--brand-tint)}
1064
+ .audit-tag.unanswered{color:var(--warn);background:color-mix(in srgb,var(--warn) 12%,transparent)}
1065
+ .seg-item .seg-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:6px;vertical-align:1px}
1066
+ .seg-item .seg-count{margin-left:6px;font-size:11px;color:var(--dim);font-variant-numeric:tabular-nums}
1067
+ @media (max-width:640px){
1068
+ .audit-list{grid-template-columns:max-content minmax(0,1fr);row-gap:0}
1069
+ .audit-row{grid-template-columns:max-content minmax(0,1fr);row-gap:3px}
1070
+ .audit-time{grid-column:1}
1071
+ .audit-pill{grid-column:2;justify-self:start}
1072
+ .audit-what{grid-column:1/-1}
1073
+ .audit-notes{grid-column:1/-1;justify-self:start}
1074
+ .audit-notes:empty{display:none}
1007
1075
  }
1008
1076
  /* A row of things to do with what is above them. Links styled as the ghost
1009
1077
  buttons beside them, because a download is a link and pretending otherwise
@@ -1027,8 +1095,56 @@ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
1027
1095
  .seg{display:inline-flex;gap:2px;padding:2px;border-radius:9px;background:var(--field);margin:2px 0 12px}
1028
1096
  /* The presets and the calendar on one line, on the same baseline. */
1029
1097
  .usage-window{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
1030
- .perms-card{display:flex;flex-direction:column;gap:8px}
1098
+ .perms-card{display:flex;flex-direction:column;gap:10px}
1031
1099
  .perms-card .setting-list{margin:0}
1100
+ /* The mode, as the one fact the rest of the page qualifies. */
1101
+ .perm-mode{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
1102
+ .perm-mode-name{
1103
+ display:inline-flex;align-items:center;gap:7px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
1104
+ font-size:13px;font-weight:600;padding:6px 12px 6px 10px;border-radius:999px;
1105
+ }
1106
+ .perm-mode-name::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
1107
+ .perm-mode-name.read-only{color:var(--ok);background:color-mix(in srgb,var(--ok) 12%,transparent)}
1108
+ .perm-mode-name.workspace-write{color:var(--brand-ink);background:var(--brand-tint)}
1109
+ .perm-mode-name.full-access{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
1110
+ .perm-mode-steps{display:flex;gap:4px;flex-wrap:wrap}
1111
+ .perm-mode-step{font-size:11.5px;color:var(--dim);padding:3px 9px;border-radius:6px;background:var(--field)}
1112
+ .perm-mode-step.on{color:var(--fg);background:var(--bg);font-weight:600}
1113
+ /* Rules, one group per verdict, each rule a chip in that verdict's colour. */
1114
+ .perm-group{display:flex;flex-direction:column;gap:6px}
1115
+ .perm-group-head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600}
1116
+ .perm-group-head .n{font-weight:400;color:var(--dim);font-variant-numeric:tabular-nums}
1117
+ .perm-group-head.allow{color:var(--ok)} .perm-group-head.ask{color:var(--warn)}
1118
+ .perm-group-head.deny{color:var(--err)} .perm-group-head.session{color:var(--brand-ink)}
1119
+ .perm-chips{display:flex;flex-wrap:wrap;gap:5px}
1120
+ .perm-chip{
1121
+ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:12px;
1122
+ padding:3px 9px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1123
+ }
1124
+ .perm-chip.allow{color:var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent)}
1125
+ .perm-chip.ask{color:var(--warn);background:color-mix(in srgb,var(--warn) 12%,transparent)}
1126
+ .perm-chip.deny{color:var(--err);background:color-mix(in srgb,var(--err) 10%,transparent)}
1127
+ .perm-chip.session{color:var(--brand-ink);background:var(--brand-tint)}
1128
+ .perm-empty{
1129
+ display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dim);
1130
+ padding:10px 12px;border-radius:8px;background:var(--field);
1131
+ }
1132
+ .perm-roots{
1133
+ display:flex;flex-direction:column;max-height:280px;overflow-y:auto;
1134
+ background:var(--field);border-radius:8px;padding:4px;
1135
+ }
1136
+ .perm-root{
1137
+ display:flex;align-items:baseline;gap:0;min-width:0;padding:4px 8px;border-radius:6px;
1138
+ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:12px;white-space:nowrap;
1139
+ }
1140
+ .perm-root:hover{background:var(--hover)}
1141
+ .perm-root .dir{color:var(--dim);overflow:hidden;text-overflow:ellipsis;min-width:0;direction:rtl;text-align:left}
1142
+ .perm-root .dir bdi{direction:ltr}
1143
+ .perm-root .base{color:var(--fg);font-weight:600;flex:none}
1144
+ .perm-count{
1145
+ display:inline-block;margin-left:6px;font-size:11px;font-weight:600;color:var(--dim);
1146
+ background:var(--field);padding:1px 7px;border-radius:999px;vertical-align:1px;
1147
+ }
1032
1148
  /* ---- date range picker ----------------------------------------------------
1033
1149
  The site's own component, class for class. Borderless like everything else
1034
1150
  here: fills for the chosen days, a shadow to lift the panel, and no ring on
@@ -1188,6 +1304,17 @@ details.provider>summary:hover{background:var(--hover)}
1188
1304
  .tag{font-size:10.5px;color:var(--dim);background:var(--field);border-radius:4px;padding:1px 5px}
1189
1305
  .tag.rec{color:var(--bg);background:var(--accent);opacity:.85}
1190
1306
  .row-head .ghost{margin-left:auto;height:26px;font-size:12px}
1307
+ /* A second action sits beside the first rather than splitting the free space
1308
+ * with it. */
1309
+ .row-head .ghost+.ghost,.row-head .ghost+.switch,.row-head .switch+.ghost{margin-left:0}
1310
+ .routine-where{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
1311
+ .routine-history{margin-top:6px}
1312
+ .routine-history>summary{cursor:pointer;width:max-content}
1313
+ .routine-runs{display:flex;flex-direction:column;gap:2px;margin-top:6px}
1314
+ .routine-run{display:flex;gap:10px;align-items:baseline;text-align:left;border:0;background:transparent;color:var(--fg);font:inherit;padding:4px 6px;border-radius:6px}
1315
+ button.routine-run{cursor:pointer}
1316
+ button.routine-run:hover{background:var(--hover)}
1317
+ .routine-run.failed{color:var(--err)}
1191
1318
  .small{font-size:12px;white-space:pre-wrap;word-break:break-all}
1192
1319
  .keyrow{display:flex;gap:6px;margin:8px 0 6px}
1193
1320
  .keyrow input{
@@ -1207,6 +1334,25 @@ details.provider>summary:hover{background:var(--hover)}
1207
1334
  nothing else, and now a subscriber gets a Top up row above it — the thing
1208
1335
  a subscriber does most often here, which had been the fourth entry of a
1209
1336
  menu behind the name. */
1337
+ /* The desktop update hint, above the account. A filled row, never an
1338
+ outline: a quiet fill for an ordinary release, the accent for one marked
1339
+ important. The window's menu and the dialogs are the loud path; this is the
1340
+ one that waits to be clicked. */
1341
+ #update-hint{flex:0 0 auto;padding:4px 8px 0}
1342
+ #update-hint button{
1343
+ width:100%;height:32px;display:flex;align-items:center;gap:8px;
1344
+ padding:0 10px;border:0;border-radius:8px;background:var(--field);color:var(--fg);
1345
+ font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
1346
+ }
1347
+ #update-hint button:hover{background:var(--field-focus)}
1348
+ #update-hint button:disabled{cursor:default}
1349
+ #update-hint .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:0 0 auto}
1350
+ #update-hint .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1351
+ #update-hint .pct{font-size:11px;color:var(--dim)}
1352
+ #update-hint.important button{background:var(--accent);color:var(--bg)}
1353
+ #update-hint.important button:hover{opacity:.9}
1354
+ #update-hint.important .dot{background:var(--bg)}
1355
+ #update-hint.important .pct{color:inherit;opacity:.8}
1210
1356
  #account{
1211
1357
  display:flex;flex-direction:column;align-items:stretch;gap:2px;
1212
1358
  flex:0 0 auto;padding:8px;
@@ -1545,7 +1691,17 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1545
1691
  /* A dash rather than a bullet: three list markers down the side of a 200px
1546
1692
  * column read as a form to fill in, which is the thing on the other side. */
1547
1693
  .cn-points li::before{content:"—";position:absolute;left:0;opacity:.55}
1548
- .cn-main{display:flex;flex-direction:column;min-width:0}
1694
+ .cn-main{display:flex;flex-direction:column;min-width:0;position:relative}
1695
+ /* What the ? explains, as a bubble under it rather than a paragraph in the
1696
+ * body: peeking at a detail must not push the field and the buttons down, or
1697
+ * the button being aimed at moves out from under the pointer. The row menu's
1698
+ * fill and shadow, and no outline, like every other thing that floats here. */
1699
+ .cn-tip{
1700
+ position:absolute;top:50px;right:16px;z-index:5;width:min(300px,calc(100% - 32px));
1701
+ padding:10px 12px;border-radius:10px;background:var(--bg);
1702
+ box-shadow:0 1px 2px #0000000f,0 8px 28px #00000024;
1703
+ }
1704
+ .cn-tip>div+div{margin-top:4px}
1549
1705
  /* Below this the two columns would leave the form narrower than its own
1550
1706
  * labels, which is the width the fields actually need. */
1551
1707
  @media (max-width:620px){
@@ -1594,6 +1750,9 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1594
1750
  click. */
1595
1751
  .cn-note{font-size:11.5px;color:var(--dim);line-height:1.45}
1596
1752
  .cn-note .mono{white-space:nowrap}
1753
+ .cn-local{display:flex;gap:6px;align-items:flex-start}
1754
+ .cn-local .ico-sm{width:13px;height:13px;flex:0 0 auto;margin-top:1.5px;color:var(--fg)}
1755
+ .cn-local strong{color:var(--fg);font-weight:600}
1597
1756
  /* The dialog ends on its buttons, so it needs the padding the shared rule
1598
1757
  leaves to whatever follows. */
1599
1758
  #connect .dlg-actions{padding:16px 20px 18px}
@@ -1603,10 +1762,15 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1603
1762
  /* An anchor, not a button, so the shared button.quiet rule does not reach it —
1604
1763
  and it is the one thing here that leaves the app, so it sits apart from the
1605
1764
  two that do not. */
1606
- #connect .dlg-actions a.quiet{
1607
- margin-left:auto;color:var(--dim);text-decoration:none;font-size:12.5px;
1608
- }
1609
- #connect .dlg-actions a.quiet:hover{color:var(--fg);text-decoration:underline}
1765
+ #connect .dlg-actions a.quiet{margin-left:auto;text-decoration:none}
1766
+ /* Cancel and Get a key are buttons, and look it: the grey fill the other
1767
+ * dialog buttons have, where Connect carries the colour. Bare text beside a
1768
+ * filled button read as a caption, not as something to press. */
1769
+ #connect .dlg-actions button.quiet,#connect .dlg-actions a.quiet{
1770
+ padding:8px 14px;border-radius:9px;background:var(--field);color:var(--fg);
1771
+ font-size:13.5px;line-height:normal;
1772
+ }
1773
+ #connect .dlg-actions button.quiet:hover,#connect .dlg-actions a.quiet:hover{background:var(--sel)}
1610
1774
  .pp-head{padding:12px 12px 6px}
1611
1775
  .pp-head input{
1612
1776
  width:100%;height:34px;border:0;border-radius:9px;background:var(--field);color:var(--fg);
@@ -1635,6 +1799,18 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
1635
1799
  .chip:hover{background:var(--sel)}
1636
1800
  .chip.on{background:var(--accent);color:var(--bg)}
1637
1801
  .chip .sub{font-size:11px;opacity:.6}
1802
+ /* A pill, not a word: .sub beside it is a quieter copy of the name, and this
1803
+ is a different kind of claim about the model rather than more of its name.
1804
+ Same geometry as .mk-tag and the picker's own. Both other chip states repaint
1805
+ the background this sits on — hover takes var(--sel), selected takes the
1806
+ accent — so the pill is moved each time rather than dissolving into it. */
1807
+ .chip .free{
1808
+ flex:0 0 auto;padding:1px 5px;border-radius:4px;background:var(--sel);
1809
+ color:var(--dim);font-size:10px;font-weight:500;letter-spacing:.02em;
1810
+ text-transform:uppercase;
1811
+ }
1812
+ .chip:hover .free{background:var(--field)}
1813
+ .chip.on .free{background:var(--bg);color:var(--accent)}
1638
1814
  .panel .row-head h2{margin:0}
1639
1815
  .panel .row-head{margin-bottom:14px}
1640
1816
  .muted{color:var(--dim)}
@@ -1729,15 +1905,25 @@ pre.diff{margin:0;font-size:12px;line-height:1.5;overflow-x:auto}
1729
1905
  .d-del{background:var(--del-bg);color:var(--del)}
1730
1906
  .d-same{color:var(--dim)}
1731
1907
 
1732
- details.work{border-radius:10px;margin:0 0 12px;background:var(--panel)}
1908
+ /* A line of text, not a box: it reads as part of the reply — what was done
1909
+ * between two paragraphs — and the box is kept for what opens under it. The
1910
+ * chevron trails the words, where it points at what a click will show. */
1911
+ details.work{margin:0 0 12px}
1733
1912
  details.work>summary{
1734
- padding:6px 12px;cursor:pointer;font-size:12px;color:var(--dim);list-style:none;
1735
- display:flex;gap:8px;align-items:center;
1913
+ padding:3px 0;cursor:pointer;font-size:13.5px;color:var(--dim);list-style:none;
1914
+ display:flex;gap:6px;align-items:center;width:max-content;max-width:100%;
1736
1915
  }
1916
+ details.work>summary:hover{color:var(--fg)}
1737
1917
  details.work>summary::-webkit-details-marker{display:none}
1738
- details.work>summary::before{content:"▸";display:inline-block;transition:transform .15s}
1739
- details.work[open]>summary::before{transform:rotate(90deg)}
1740
- .work-body{padding:0 8px 8px}
1918
+ details.work>summary::after{
1919
+ content:"";width:6px;height:6px;flex:0 0 auto;margin-left:2px;
1920
+ border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
1921
+ transform:rotate(-45deg);transition:transform .15s;
1922
+ }
1923
+ details.work[open]>summary::after{transform:rotate(45deg)}
1924
+ .work-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
1925
+ details.work.failed>summary{color:var(--err)}
1926
+ .work-body{padding:8px;margin-top:4px;border-radius:10px;background:var(--panel)}
1741
1927
  .work-body details.tool{margin-bottom:6px}
1742
1928
  /* the working indicator */
1743
1929
  .working{display:flex;align-items:center;gap:9px;color:var(--dim);font-size:13.5px;margin:0 0 10px}
@@ -1798,9 +1984,28 @@ details.thinking[open]>summary::after{transform:translateY(-2px) rotate(45deg)}
1798
1984
  /* composer */
1799
1985
  form{padding:6px 24px 18px;background:var(--bg)}
1800
1986
  .composer{
1987
+ position:relative;
1801
1988
  max-width:720px;margin:0 auto;border-radius:16px;
1802
1989
  background:var(--field);padding:16px 16px 10px;transition:background .15s,box-shadow .15s;
1803
1990
  }
1991
+ /* The top edge, as a grab handle. It spans the box rather than sitting in a
1992
+ corner, because the edge is what a person aims at; the bar only appears
1993
+ under the cursor, so an idle composer keeps its clean top line. */
1994
+ #grip{
1995
+ position:absolute;top:0;left:0;right:0;height:12px;cursor:ns-resize;
1996
+ display:grid;place-items:center;touch-action:none;
1997
+ }
1998
+ /* Faint rather than hidden: a handle nobody can see is a handle nobody
1999
+ finds. A pill, not a rule across the top — this is a thing to grab, and the
2000
+ design carries no borders. */
2001
+ #grip::before{
2002
+ content:"";width:32px;height:3px;border-radius:999px;
2003
+ background:var(--dim);opacity:.2;transition:opacity .12s,width .12s;
2004
+ }
2005
+ #grip:hover::before,#grip.dragging::before{opacity:.5;width:40px}
2006
+ /* While dragging, the pointer owns the screen: without this a fast drag
2007
+ selects the transcript behind it. */
2008
+ body.resizing{user-select:none;cursor:ns-resize}
1804
2009
  .composer:focus-within{background:var(--field-focus)}
1805
2010
  textarea{
1806
2011
  width:100%;resize:none;min-height:56px;max-height:32vh;padding:0;border:0;
@@ -1823,7 +2028,23 @@ textarea::placeholder{color:var(--dim)}
1823
2028
  the field only has to agree on where each glyph lands, not on what is
1824
2029
  drawn there. The selection is translucent so the words under it stay
1825
2030
  readable; an opaque highlight would sit on top of the mirror. */
1826
- .input-wrap textarea{color:transparent;caret-color:var(--brand)}
2031
+ /* The native caret is hidden and one is drawn instead. A textarea offers
2032
+ caret-color and nothing else — no width — and at one device pixel the bar
2033
+ is hard to find on a pale field. See placeCaret(). */
2034
+ .input-wrap textarea{color:transparent;caret-color:transparent}
2035
+ #caret{
2036
+ position:absolute;width:2px;border-radius:1px;background:var(--brand);
2037
+ pointer-events:none;z-index:2;opacity:0;
2038
+ }
2039
+ #caret.on{animation:caret-blink 1.06s steps(1,end) infinite}
2040
+ @keyframes caret-blink{0%,55%{opacity:1}56%,100%{opacity:0}}
2041
+ /* Solid while the caret is moving: a bar that blinks out mid-keystroke reads
2042
+ as a dropped character. The class is taken off and put back to restart the
2043
+ animation, which is what makes the phase reset. */
2044
+ #caret.on.moving{animation:none;opacity:1}
2045
+ @media (prefers-reduced-motion:reduce){#caret.on{animation:none;opacity:1}}
2046
+ /* Measures where the caret goes: the mirror's metrics, none of its ink. */
2047
+ #caret-probe{visibility:hidden}
1827
2048
  .input-wrap textarea::selection{background:var(--brand-tint)}
1828
2049
  /* Every metric that decides where a glyph lands has to match the field: font,
1829
2050
  line-height, wrapping, and the padding, which is zero on this textarea.
@@ -1831,7 +2052,7 @@ textarea::placeholder{color:var(--dim)}
1831
2052
  glyphs land where the textarea puts them, and the chip's background — drawn
1832
2053
  outside its own advance width — has somewhere to go instead of being cut off
1833
2054
  at the start of a line. */
1834
- #input-mirror{
2055
+ #input-mirror,#caret-probe{
1835
2056
  position:absolute;top:0;bottom:0;left:-6px;right:-6px;padding:0 6px;border:0;
1836
2057
  font:inherit;line-height:1.6;white-space:pre-wrap;overflow-wrap:break-word;
1837
2058
  color:var(--fg);pointer-events:none;overflow:hidden;
@@ -1923,6 +2144,41 @@ textarea.boxed:focus{outline:none;background:var(--field-focus)}
1923
2144
  .effort-name.on{color:var(--fg);font-weight:600}
1924
2145
  #attachments{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
1925
2146
  #attachments:empty{display:none}
2147
+ /* Outside the composer, tracking its width so the rows line up with the box
2148
+ they are waiting on. min-width:0 twice over — here and on the line inside
2149
+ each row — because a flex item defaults to refusing to shrink below its
2150
+ content, and one long queued line would push the page sideways at phone
2151
+ width instead of being cut. */
2152
+ #queued{
2153
+ display:flex;flex-direction:column;gap:4px;min-width:0;
2154
+ max-width:720px;margin:0 auto 6px;
2155
+ }
2156
+ #queued:empty{display:none}
2157
+ /* Full width, one per row, because these are sentences rather than file
2158
+ names: a chip that fits three words of a queued message says nothing about
2159
+ which message it is. --panel, not --field: out here the backdrop is the
2160
+ page, and the field's 2% wash is invisible against it. */
2161
+ .q{display:flex;align-items:center;gap:8px;background:var(--panel);border-radius:10px;padding:7px 10px 7px 12px;font-size:12px;min-width:0}
2162
+ /* min-width:0 or the ellipsis never engages: a flex item refuses to shrink
2163
+ below its content by default, so at phone width the row pushed the page
2164
+ sideways instead of cutting the line. */
2165
+ .q .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dim)}
2166
+ /* Not ".mark": that is the connector glyph, and it is a fixed 18px box. */
2167
+ .q .tag{flex:0 0 auto;color:var(--dim);font-size:11px}
2168
+ /* Joining the turn already running, rather than waiting behind it. The brand
2169
+ colour is what this app uses for the harness being alive, which is exactly
2170
+ what the difference is. */
2171
+ .q.soon .tag{color:var(--brand-ink)}
2172
+ :root[data-theme="dark"] .q.soon .tag{color:var(--brand)}
2173
+ .q .go{
2174
+ flex:0 0 auto;height:22px;padding:0 9px;border:0;border-radius:7px;
2175
+ background:var(--hover);color:var(--fg);cursor:pointer;
2176
+ font:inherit;font-size:11px;white-space:nowrap;
2177
+ }
2178
+ .q .go:hover{background:var(--brand-tint);color:var(--brand-ink)}
2179
+ :root[data-theme="dark"] .q .go:hover{color:var(--brand)}
2180
+ .q .x{border:0;background:transparent;color:var(--dim);cursor:pointer;font:inherit;padding:0 2px}
2181
+ .q .x:hover{color:var(--err)}
1926
2182
  .att{
1927
2183
  display:flex;align-items:center;gap:7px;background:var(--bg);border-radius:8px;
1928
2184
  padding:5px 8px;font-size:12px;max-width:260px;
@@ -2013,6 +2269,11 @@ button.primary{
2013
2269
  }
2014
2270
  button.primary svg{width:16px;height:16px}
2015
2271
  button.primary:disabled{opacity:.3;cursor:default}
2272
+ /* Idle with messages still waiting: send releases them, and the button says
2273
+ so by quietly breathing rather than by growing an outline. */
2274
+ #send.has-queue{animation:queue-wait 2.4s ease-in-out infinite}
2275
+ @keyframes queue-wait{0%,100%{opacity:1}50%{opacity:.55}}
2276
+ @media (prefers-reduced-motion:reduce){#send.has-queue{animation:none;opacity:.8}}
2016
2277
  button.ghost,a.ghost{
2017
2278
  height:30px;padding:0 12px;border:0;border-radius:8px;
2018
2279
  background:var(--sel);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;
@@ -2119,7 +2380,13 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2119
2380
  .dlg-actions button.approve kbd{background:#ffffff26}
2120
2381
  /* Only while a "remember" answer is focused, and taking no room otherwise: a
2121
2382
  * reserved empty line under the buttons read as the dialog being unfinished. */
2122
- .ask-scope{padding:8px 20px 4px;font-size:11.5px;line-height:1.6;color:var(--dim)}
2383
+ .ask-scope{display:grid;padding:6px 20px 2px;font-size:11.5px;line-height:1.6;color:var(--dim)}
2384
+ .ask-scope>span{
2385
+ grid-area:1/1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
2386
+ opacity:0;pointer-events:none;transition:opacity .12s ease;
2387
+ }
2388
+ .ask-scope[data-show="once"]>[data-scope="once"],.ask-scope[data-show="session"]>[data-scope="session"],
2389
+ .ask-scope[data-show="project"]>[data-scope="project"]{opacity:1;pointer-events:auto}
2123
2390
 
2124
2391
  /* Parameters as a reader wants them: names on the left, values as values —
2125
2392
  * text with its line breaks, lists as chips — instead of JSON with line numbers. */
@@ -2207,6 +2474,10 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2207
2474
  .composer .picker{position:static;min-width:0}
2208
2475
  .composer .picker-btn{max-width:100%;height:32px}
2209
2476
  #send,#stop{flex:0 0 auto}
2477
+ /* No hover on a touch screen, so the bar is always drawn, and the strip is
2478
+ taller because a thumb is not a cursor. */
2479
+ #grip{height:18px}
2480
+ #grip::before{opacity:.3;width:40px}
2210
2481
  textarea{max-height:32dvh}
2211
2482
  /* Menus span the box rather than hang off one picker: from a picker on the
2212
2483
  right they otherwise open past the edge, where overflow:hidden loses them. */
@@ -2307,6 +2578,9 @@ const isPhone = () => PHONE_QUERY.matches;
2307
2578
  * viewBox and one stroke width with the icons in the markup.
2308
2579
  */
2309
2580
  const ICON_PATHS = {
2581
+ // A padlock: the shackle as a path, the body as a rect.
2582
+ lock: ["M5.33 7.33V5.33a2.67 2.67 0 0 1 5.34 0v2"],
2583
+ lockRects: [[3.33, 7.33, 9.34, 6.67, 1.6]],
2310
2584
  // Same drawing as the Settings item in the nav, so the model picker's
2311
2585
  // "manage" link and the place it leads to are recognisably one thing.
2312
2586
  settings: ["M12.67 4.67h-6", "M9.33 11.33H3.33"],
@@ -2627,6 +2901,7 @@ const cacheEl = document.getElementById("cache");
2627
2901
  const ctxBar = document.querySelector("#ctx i");
2628
2902
  const ctxText = document.getElementById("ctxtext");
2629
2903
  const sessionList = document.getElementById("sessions");
2904
+ const sidebarEl = document.querySelector("body > aside");
2630
2905
  const ask = document.getElementById("ask");
2631
2906
 
2632
2907
  let pendingId = null;
@@ -2756,7 +3031,7 @@ function shortModel(full) {
2756
3031
  return cut < 0 ? full : full.slice(cut + 1);
2757
3032
  }
2758
3033
 
2759
- function makePicker(id, onPick, extra) {
3034
+ function makePicker(id, onPick, extra, opts = {}) {
2760
3035
  const root = document.getElementById(id);
2761
3036
  const button = root.querySelector(".picker-btn");
2762
3037
  const label = root.querySelector(".picker-value");
@@ -2784,18 +3059,25 @@ function makePicker(id, onPick, extra) {
2784
3059
  let cursor = 0;
2785
3060
  /** Groups the user has folded away; survives repainting. */
2786
3061
  const collapsed = new Set();
3062
+ /**
3063
+ * Groups folded while searching. Kept apart from the browsing folds: a
3064
+ * search opens every vendor, because a match behind a closed header looks
3065
+ * like no result — but a header that then refuses to close is a control
3066
+ * that does nothing, and OpenRouter's twenty matches can bury the one vendor
3067
+ * being looked for. Cleared when the query changes, so each search starts
3068
+ * with everything it found on screen.
3069
+ */
3070
+ const searchFolded = new Set();
3071
+ const folded = (group) => (query ? searchFolded.has(group) : collapsed.has(group));
2787
3072
 
2788
3073
  /** The options a keystroke can land on: what is actually on screen. */
2789
- const visible = () => {
2790
- const list = matches();
2791
- if (query) return list;
2792
- return list.filter((option) => !option.group || !collapsed.has(option.group));
2793
- };
3074
+ const visible = () => matches().filter((option) => !option.group || !folded(option.group));
2794
3075
 
2795
3076
  const close = () => {
2796
3077
  menu.hidden = true;
2797
3078
  root.classList.remove("open");
2798
3079
  query = "";
3080
+ searchFolded.clear();
2799
3081
  };
2800
3082
 
2801
3083
  const matches = () => options.filter((option) => matchesQuery(option, query));
@@ -2803,12 +3085,7 @@ function makePicker(id, onPick, extra) {
2803
3085
  const paint = () => {
2804
3086
  const list = matches();
2805
3087
 
2806
- // Searching must reach into every vendor: a match hidden behind a closed
2807
- // header looks like no result at all.
2808
- const searching = query.length > 0;
2809
- const shown = list.filter(
2810
- (option) => searching || !option.group || !collapsed.has(option.group),
2811
- );
3088
+ const shown = list.filter((option) => !option.group || !folded(option.group));
2812
3089
  if (cursor >= shown.length) cursor = Math.max(0, shown.length - 1);
2813
3090
 
2814
3091
  // Only the body is rebuilt: replacing the menu itself would take the
@@ -2828,7 +3105,7 @@ function makePicker(id, onPick, extra) {
2828
3105
  if (option.group && option.group !== lastGroup) {
2829
3106
  lastGroup = option.group;
2830
3107
  const group = option.group;
2831
- const header = el("button", "menu-group" + (collapsed.has(group) && !searching ? "" : " open"));
3108
+ const header = el("button", "menu-group" + (folded(group) ? "" : " open"));
2832
3109
  header.type = "button";
2833
3110
  header.appendChild(el("span", "chev"));
2834
3111
  // The vendor's mark leads its header, so a folded list reads as a
@@ -2844,15 +3121,16 @@ function makePicker(id, onPick, extra) {
2844
3121
  header.appendChild(el("span", "g-count", String(counts.get(group) || 0)));
2845
3122
  header.addEventListener("click", (e) => {
2846
3123
  e.stopPropagation();
2847
- if (collapsed.has(group)) collapsed.delete(group);
2848
- else collapsed.add(group);
3124
+ const set = query ? searchFolded : collapsed;
3125
+ if (set.has(group)) set.delete(group);
3126
+ else set.add(group);
2849
3127
  cursor = 0;
2850
3128
  paint();
2851
3129
  });
2852
3130
  body.appendChild(header);
2853
3131
  }
2854
3132
 
2855
- if (option.group && collapsed.has(option.group) && !searching) continue;
3133
+ if (option.group && folded(option.group)) continue;
2856
3134
  const i = visibleIndex++;
2857
3135
  const item = el(
2858
3136
  "button",
@@ -2865,6 +3143,10 @@ function makePicker(id, onPick, extra) {
2865
3143
  const mark = option.mark && option.mark();
2866
3144
  if (mark) item.appendChild(mark);
2867
3145
  item.appendChild(el("span", "menu-label", option.label));
3146
+ // Beside the name rather than out at the right margin where sub sits:
3147
+ // this qualifies the model it is attached to, and a badge floated away
3148
+ // from its name is a badge the eye has to pair up again on every row.
3149
+ if (option.tag) item.appendChild(el("span", "m-tag", option.tag));
2868
3150
  if (option.sub) item.appendChild(el("span", "sub", option.sub));
2869
3151
  item.addEventListener("click", () => {
2870
3152
  close();
@@ -2888,10 +3170,14 @@ function makePicker(id, onPick, extra) {
2888
3170
 
2889
3171
  // Everything folded except the vendor holding the current model: opening
2890
3172
  // onto a dozen closed headers hides the one answer the user already has.
3173
+ // A picker may name groups that always open as well — the model picker's
3174
+ // own plan, which is where someone looking for a model should land first.
2891
3175
  collapsed.clear();
2892
3176
  const current = options.find((option) => option.value === value);
3177
+ const alwaysOpen = new Set(opts.openGroups || []);
2893
3178
  for (const option of options) {
2894
- if (option.group && option.group !== (current && current.group)) collapsed.add(option.group);
3179
+ if (!option.group || alwaysOpen.has(option.group)) continue;
3180
+ if (option.group !== (current && current.group)) collapsed.add(option.group);
2895
3181
  }
2896
3182
  cursor = Math.max(0, visible().findIndex((option) => option.value === value));
2897
3183
  // Searching costs a row of chrome and is pointless below a handful of
@@ -2912,6 +3198,7 @@ function makePicker(id, onPick, extra) {
2912
3198
  };
2913
3199
 
2914
3200
  search.addEventListener("input", () => {
3201
+ if (search.value !== query) searchFolded.clear();
2915
3202
  query = search.value;
2916
3203
  cursor = 0;
2917
3204
  paint();
@@ -3035,6 +3322,7 @@ const modelPicker = makePicker(
3035
3322
  });
3036
3323
  return link;
3037
3324
  },
3325
+ { openGroups: ["official"] },
3038
3326
  );
3039
3327
  const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
3040
3328
 
@@ -3437,7 +3725,7 @@ function workGroup() {
3437
3725
  if (!group) {
3438
3726
  group = el("details", "work");
3439
3727
  const summary = el("summary");
3440
- summary.appendChild(el("span", "work-label", "Working"));
3728
+ summary.appendChild(el("span", "work-label", t("work.working")));
3441
3729
  group.appendChild(summary);
3442
3730
  group.appendChild(el("div", "work-body"));
3443
3731
  parent.appendChild(group);
@@ -3445,10 +3733,127 @@ function workGroup() {
3445
3733
  return group;
3446
3734
  }
3447
3735
 
3736
+ /**
3737
+ * A run of tool calls, said in a line: what was done, not how many steps it
3738
+ * took.
3739
+ *
3740
+ * "3 steps" was true and told nobody anything; the reason to look at a folded
3741
+ * group is to know whether it is the part worth opening. So the line is built
3742
+ * from the calls themselves. A call that says what it is for — a command's
3743
+ * description, a subagent's task — is quoted; the rest are named by what they
3744
+ * do, and neighbours of one kind are counted rather than listed ("Read 3
3745
+ * files"). While a call is running its phrase is in the present ("Running a
3746
+ * command"), and a failure leads, in red, because that is the one thing
3747
+ * nobody should have to open the group to find. Two phrases are shown and the
3748
+ * rest counted, which is how Claude Code's desktop app reads.
3749
+ */
3448
3750
  function bumpWorkLabel(group) {
3449
- const count = group.querySelector(".work-body").children.length;
3450
- group.querySelector(".work-label").textContent =
3451
- count === 1 ? "1 step" : count + " steps";
3751
+ const cards = [...group.querySelectorAll(":scope > .work-body > details.tool")];
3752
+ const phrases = [];
3753
+ let failed = false;
3754
+ let failures = 0;
3755
+ for (const card of cards) {
3756
+ const info = card.toolInfo || {};
3757
+ const state = card.classList.contains("failed") ? "failed" : card.classList.contains("done") ? "done" : "running";
3758
+ const input = info.input && typeof info.input === "object" ? info.input : {};
3759
+ const described = typeof input.description === "string" ? input.description.trim() : "";
3760
+ if (state === "failed") {
3761
+ failed = true;
3762
+ // First, not in sequence: counted into a "+2" it would be the one
3763
+ // phrase the red line is about and the one it does not show.
3764
+ phrases.splice(failures++, 0, { text: failedPhrase(info.name, input, described) });
3765
+ continue;
3766
+ }
3767
+ if (described) {
3768
+ phrases.push({ text: described });
3769
+ continue;
3770
+ }
3771
+ const kind = workKind(info.name);
3772
+ const last = phrases.at(-1);
3773
+ if (last && last.kind === kind && last.state === state) {
3774
+ last.count += 1;
3775
+ continue;
3776
+ }
3777
+ phrases.push({ kind, state, count: 1, name: info.name, input });
3778
+ }
3779
+
3780
+ const texts = phrases.map((phrase) => phrase.text || kindPhrase(phrase));
3781
+ const shown = texts.slice(0, 2).map((text, i) => (i ? lowerFirst(text) : text));
3782
+ let label = shown.join(t("work.sep"));
3783
+ if (texts.length > 2) label += " +" + (texts.length - 2);
3784
+ group.querySelector(".work-label").textContent = label || t("work.working");
3785
+ group.classList.toggle("failed", failed);
3786
+ }
3787
+
3788
+ /** Which verb a tool gets when it did not say what it was for. */
3789
+ function workKind(name) {
3790
+ if (name === "bash") return "command";
3791
+ if (name === "read" || name === "artifact_read" || name === "mcp_read_resource") return "read";
3792
+ if (name === "write" || name === "artifact_write") return "write";
3793
+ if (name === "edit") return "edit";
3794
+ if (name === "list" || name === "mcp_list_resources") return "list";
3795
+ if (name === "glob" || name === "grep") return "search";
3796
+ if (name === "recall" || name === "memory") return "recall";
3797
+ if (name === "todo_write") return "plan";
3798
+ if (name === "skill") return "skill";
3799
+ if (name === "ask_user") return "ask";
3800
+ return "other";
3801
+ }
3802
+
3803
+ /** The last segment of a path, which is what a person recognises. */
3804
+ function baseName(value) {
3805
+ if (typeof value !== "string" || !value) return "";
3806
+ return value.replace(/\/+$/, "").split("/").pop() || value;
3807
+ }
3808
+
3809
+ function kindPhrase(phrase) {
3810
+ const now = phrase.state === "running";
3811
+ const many = phrase.count > 1;
3812
+ const file = baseName(phrase.input.path);
3813
+ switch (phrase.kind) {
3814
+ case "command":
3815
+ return now ? t("work.commandNow") : many ? t("work.commandN", { n: phrase.count }) : t("work.command");
3816
+ case "read":
3817
+ case "write":
3818
+ case "edit":
3819
+ if (now) return t("work." + phrase.kind + "Now", { file: file || "…" });
3820
+ if (many) return t("work." + phrase.kind + "N", { n: phrase.count });
3821
+ return t("work." + phrase.kind, { file: file || "…" });
3822
+ case "list":
3823
+ // No path is the working directory, and "Listed ." reads as a typo.
3824
+ if (!file || file === ".") return t(now ? "work.listHereNow" : "work.listHere");
3825
+ return t(now ? "work.listNow" : "work.list", { dir: file });
3826
+ case "search":
3827
+ return now ? t("work.searchNow") : many ? t("work.searchN", { n: phrase.count }) : t("work.search");
3828
+ case "recall":
3829
+ case "plan":
3830
+ case "ask":
3831
+ return t("work." + phrase.kind + (now ? "Now" : ""));
3832
+ case "skill":
3833
+ return t(now ? "work.skillNow" : "work.skill", { name: String(phrase.input.name || "") });
3834
+ default:
3835
+ return t(now ? "work.otherNow" : "work.other", { tool: toolTitle(phrase.name, phrase.input) });
3836
+ }
3837
+ }
3838
+
3839
+ function failedPhrase(name, input, described) {
3840
+ if (name === "bash") {
3841
+ const command = typeof input.command === "string" ? input.command.trim().split("\n")[0] : "";
3842
+ const what = described || (command.length > 32 ? command.slice(0, 32) + "…" : command);
3843
+ return t("work.failedRun", { what: what || t("work.aCommand") });
3844
+ }
3845
+ return t("work.failed", { tool: described || toolTitle(name, input) });
3846
+ }
3847
+
3848
+ /**
3849
+ * The second phrase continues a sentence, so it starts lower-case — unless it
3850
+ * opens with a name or an acronym, which keeps its capital.
3851
+ */
3852
+ function lowerFirst(text) {
3853
+ if (text.length > 1 && text[1] === text[1].toLowerCase() && /^[A-Z]/.test(text)) {
3854
+ return text[0].toLowerCase() + text.slice(1);
3855
+ }
3856
+ return text;
3452
3857
  }
3453
3858
 
3454
3859
  // --- tool presentation -----------------------------------------------------
@@ -3569,15 +3974,61 @@ function catalogSummary(text) {
3569
3974
  return "";
3570
3975
  }
3571
3976
 
3572
- function toolCard(id, title, name, input) {
3977
+ /**
3978
+ * A card for a call the model is still writing: the name is known, the
3979
+ * arguments are not. Without it a long file write was a reply that said "now
3980
+ * writing the file" and then showed nothing until the whole file had streamed.
3981
+ */
3982
+ function pendingToolCard(id, name) {
3983
+ if (P.tools.has(id)) return;
3573
3984
  const card = el("details", "tool");
3574
3985
  const summary = el("summary");
3575
3986
  summary.appendChild(el("span", "dot"));
3576
- summary.appendChild(el("span", "name", toolTitle(name, input, title)));
3987
+ summary.appendChild(el("span", "name", toolTitle(name)));
3577
3988
  summary.appendChild(el("span", "ms", ""));
3578
3989
  card.appendChild(summary);
3579
-
3580
3990
  const body = el("div", "tool-body");
3991
+ card.appendChild(body);
3992
+
3993
+ card.toolInfo = { name };
3994
+ const group = workGroup();
3995
+ group.querySelector(".work-body").appendChild(card);
3996
+ bumpWorkLabel(group);
3997
+ P.tools.set(id, { card, body, name, input: undefined, group, pending: true });
3998
+ P.streaming = null;
3999
+ follow();
4000
+ }
4001
+
4002
+ /**
4003
+ * Drop the cards for calls that were announced and never ran — an unknown tool
4004
+ * or bad input is answered without a tool_start, and a retried request gets
4005
+ * fresh ids. Left alone they would pulse forever.
4006
+ */
4007
+ function dropPendingTools() {
4008
+ for (const [id, entry] of P.tools) {
4009
+ if (!entry.pending) continue;
4010
+ entry.card.remove();
4011
+ P.tools.delete(id);
4012
+ const count = entry.group.querySelector(".work-body").children.length;
4013
+ if (count) bumpWorkLabel(entry.group);
4014
+ else entry.group.remove();
4015
+ }
4016
+ }
4017
+
4018
+ function toolCard(id, title, name, input) {
4019
+ const announced = P.tools.get(id);
4020
+ const card = announced ? announced.card : el("details", "tool");
4021
+ if (announced) {
4022
+ card.querySelector(".name").textContent = toolTitle(name, input, title);
4023
+ } else {
4024
+ const summary = el("summary");
4025
+ summary.appendChild(el("span", "dot"));
4026
+ summary.appendChild(el("span", "name", toolTitle(name, input, title)));
4027
+ summary.appendChild(el("span", "ms", ""));
4028
+ card.appendChild(summary);
4029
+ }
4030
+
4031
+ const body = announced ? announced.body : el("div", "tool-body");
3581
4032
  // An app call is its arguments; the app, tool and version are already the
3582
4033
  // title or plumbing. A call with nothing to show gets no "input" label over
3583
4034
  // an empty {} — which was most of what a catalog lookup used to draw. A
@@ -3594,6 +4045,15 @@ function toolCard(id, title, name, input) {
3594
4045
  body.appendChild(pre);
3595
4046
  }
3596
4047
  }
4048
+ card.toolInfo = { name, input };
4049
+ if (announced) {
4050
+ P.tools.set(id, { card, body, name, input, group: announced.group });
4051
+ // The arguments have arrived, and with them the description or the path
4052
+ // the line was waiting for.
4053
+ bumpWorkLabel(announced.group);
4054
+ follow();
4055
+ return;
4056
+ }
3597
4057
  card.appendChild(body);
3598
4058
 
3599
4059
  const group = workGroup();
@@ -3609,6 +4069,8 @@ function finishTool(id, result, ms, isError) {
3609
4069
  if (!entry) return;
3610
4070
  entry.card.classList.add(isError ? "failed" : "done");
3611
4071
  entry.card.querySelector(".ms").textContent = ms + "ms";
4072
+ // Running becomes ran, or the group turns red.
4073
+ if (entry.group) bumpWorkLabel(entry.group);
3612
4074
 
3613
4075
  const text = (result || []).map((part) => (part.type === "text" ? part.text : "")).join("");
3614
4076
  entry.body.appendChild(el("div", "label", isError ? "error" : "result"));
@@ -3664,7 +4126,7 @@ function pane(id) {
3664
4126
  const node = el("div", "pane");
3665
4127
  node.hidden = true;
3666
4128
  log.appendChild(node);
3667
- entry = { node, title: t("session.untitled"), cwd: "", busy: false, turn: null, streaming: null, tools: new Map(), thinking: null };
4129
+ entry = { node, title: t("session.untitled"), cwd: "", busy: false, queued: [], turn: null, streaming: null, tools: new Map(), thinking: null };
3668
4130
  panes.set(id, entry);
3669
4131
  }
3670
4132
  return entry;
@@ -3811,6 +4273,10 @@ function applyState(s) {
3811
4273
  ctxText.textContent = pct + "%";
3812
4274
  }
3813
4275
  setBusy(Boolean(s.busy));
4276
+ // Addressed by session: a reload and a tab switch both land here, and the
4277
+ // pane this state describes is not always the one an event last touched.
4278
+ if (s.sessionId) pane(s.sessionId).queued = s.queued || [];
4279
+ renderQueued();
3814
4280
  }
3815
4281
 
3816
4282
  let workingNode = null;
@@ -3865,6 +4331,9 @@ function setBusy(busy) {
3865
4331
  sendBtn.hidden = busy;
3866
4332
  stopBtn.hidden = !busy;
3867
4333
  if (!busy) stopWorking();
4334
+ // The row itself does not change, but whether the queue can be released by
4335
+ // hand does, and that is drawn on the send button.
4336
+ renderQueued();
3868
4337
  }
3869
4338
 
3870
4339
  /** "just now", "12m", "3h", "yesterday", "6 Aug" — scannable at a glance. */
@@ -3906,6 +4375,24 @@ function toggleProject(cwd, open) {
3906
4375
  paintSessions();
3907
4376
  }
3908
4377
 
4378
+ /** The key the routine group's open state is kept under, beside the projects'. */
4379
+ const ROUTINE_GROUP = "\u0000routines";
4380
+
4381
+ /**
4382
+ * The header over routine runs. A fold like a project's, with the clock the
4383
+ * Routines page uses, and none of a project's actions: there is no new session
4384
+ * to start in it, and deleting runs is done row by row.
4385
+ */
4386
+ function routineHead(count, open) {
4387
+ const head = el("button", "fold" + (open ? " open" : ""));
4388
+ head.type = "button";
4389
+ head.appendChild(navIcon("nav-routines"));
4390
+ head.appendChild(el("span", "fold-name", t("session.routines")));
4391
+ head.appendChild(el("span", "count", String(count)));
4392
+ head.addEventListener("click", () => toggleProject(ROUTINE_GROUP, open));
4393
+ return head;
4394
+ }
4395
+
3909
4396
  /** A project header: a folder, a name, and how many sessions it holds. */
3910
4397
  function projectHead(cwd, name, count, open) {
3911
4398
  const head = el("button", "fold" + (open ? " open" : ""));
@@ -3949,7 +4436,17 @@ function projectHead(cwd, name, count, open) {
3949
4436
  more.addEventListener("click", (e) => {
3950
4437
  e.stopPropagation();
3951
4438
  if (menuRow === head) return closeRowMenu();
4439
+ openMenu(more);
4440
+ });
4441
+ // The same menu under the pointer: a right-click on a project is asking for
4442
+ // it, and the browser's own (Back, Reload, View Source) is about the page.
4443
+ head.addEventListener("contextmenu", (e) => {
4444
+ e.preventDefault();
4445
+ e.stopPropagation();
4446
+ openMenu(pointAnchor(e));
4447
+ });
3952
4448
 
4449
+ function openMenu(anchor) {
3953
4450
  // Armed once before it fires, like the session row it borrows from: this
3954
4451
  // is a modal's worth of consequence reached from a list row, and the
3955
4452
  // consequence is spelled out where a dialog would put it.
@@ -3958,7 +4455,7 @@ function projectHead(cwd, name, count, open) {
3958
4455
  del.classList.add("armed");
3959
4456
  del.querySelector(".menu-label").textContent = t("projects.deleteConfirm");
3960
4457
  rowMenu.replaceChildren(el("div", "menu-note", t("projects.deleteNote")), del);
3961
- placeRowMenu(more);
4458
+ placeRowMenu(anchor);
3962
4459
  return;
3963
4460
  }
3964
4461
  closeRowMenu();
@@ -3971,6 +4468,20 @@ function projectHead(cwd, name, count, open) {
3971
4468
  closeRowMenu();
3972
4469
  void chooseProject(cwd);
3973
4470
  } }),
4471
+ menuItem(t("projects.rename"), { key: "n", run: () => {
4472
+ closeRowMenu();
4473
+ void renameProjectUi(cwd, name);
4474
+ } }),
4475
+ menuItem(t(IS_MAC ? "projects.revealMac" : IS_WIN ? "projects.revealWin" : "projects.reveal"), { key: "f", run: () => {
4476
+ closeRowMenu();
4477
+ void api("/reveal", { path: cwd }).then((body) => {
4478
+ if (body && body.error) addNotice(body.error, "err");
4479
+ });
4480
+ } }),
4481
+ menuItem(t("projects.export"), { key: "e", run: () => {
4482
+ closeRowMenu();
4483
+ exportProjectUi(cwd);
4484
+ } }),
3974
4485
  menuItem(t(pinned ? "projects.unpin" : "projects.pin"), { key: "p", run: () => {
3975
4486
  closeRowMenu();
3976
4487
  void togglePin("project", cwd, !pinned);
@@ -3992,8 +4503,8 @@ function projectHead(cwd, name, count, open) {
3992
4503
  } }));
3993
4504
  }
3994
4505
  items.push(menuSep(), del);
3995
- openRowMenu(more, head, items);
3996
- });
4506
+ openRowMenu(anchor, head, items);
4507
+ }
3997
4508
 
3998
4509
  // The header is a button, so the menu control cannot live inside it.
3999
4510
  const wrap = el("div", "fold-row");
@@ -4147,8 +4658,14 @@ function paintSessions() {
4147
4658
  const active = sessionRows.find((s) => s.current);
4148
4659
  const here = active ? active.cwd : (projects.find((p) => p.current) || {}).path;
4149
4660
 
4661
+ // A routine's runs are kept together rather than mixed into the project's
4662
+ // own work: they arrive on their own, often overnight, and the question
4663
+ // someone opens them with is "what did my routines do", not "what was I
4664
+ // doing in this project". Claude Code's desktop app draws the same line.
4665
+ const scheduled = sessionRows.filter((s) => s.routine);
4150
4666
  const byProject = new Map();
4151
4667
  for (const s of sessionRows) {
4668
+ if (s.routine) continue;
4152
4669
  const key = s.cwd || "";
4153
4670
  if (!byProject.has(key)) byProject.set(key, []);
4154
4671
  byProject.get(key).push(s);
@@ -4172,7 +4689,19 @@ function paintSessions() {
4172
4689
  // Everything here belongs to the project already named at the top of the
4173
4690
  // sidebar: a header repeating it is noise, and the rows do not need
4174
4691
  // indenting under a heading that is not there.
4175
- const named = !(order.length === 1 && order[0] === here);
4692
+ const named = !(order.length === 1 && order[0] === here) || scheduled.length > 0;
4693
+
4694
+ if (scheduled.length) {
4695
+ const open = ROUTINE_GROUP in projectOpen ? projectOpen[ROUTINE_GROUP] === true : true;
4696
+ sessionList.appendChild(routineHead(scheduled.length, open));
4697
+ if (open) {
4698
+ const all = showingAll.has(ROUTINE_GROUP) || scheduled.length <= PREVIEW + 1;
4699
+ for (const s of all ? scheduled : scheduled.slice(0, PREVIEW)) {
4700
+ sessionList.appendChild(sessionRow(s, true));
4701
+ }
4702
+ if (!all) sessionList.appendChild(showMore(ROUTINE_GROUP, true));
4703
+ }
4704
+ }
4176
4705
 
4177
4706
  for (const cwd of order) {
4178
4707
  const list = arrange(byProject.get(cwd), (s) => s.id, sessionOrder[cwd], (s) => s.pinned);
@@ -4387,6 +4916,15 @@ function menuSep() {
4387
4916
  return el("div", "menu-sep");
4388
4917
  }
4389
4918
 
4919
+ /**
4920
+ * Something placeRowMenu can hang a menu from when there is no button: the
4921
+ * point a right-click landed on, as a rectangle with no size.
4922
+ */
4923
+ function pointAnchor(e) {
4924
+ const rect = { left: e.clientX, right: e.clientX, top: e.clientY, bottom: e.clientY, width: 0, height: 0 };
4925
+ return { getBoundingClientRect: () => rect };
4926
+ }
4927
+
4390
4928
  function openRowMenu(button, row, items) {
4391
4929
  closeRowMenu();
4392
4930
  rowMenu.replaceChildren(...items);
@@ -4415,6 +4953,13 @@ document.addEventListener("keydown", (e) => {
4415
4953
  });
4416
4954
  window.addEventListener("resize", closeRowMenu);
4417
4955
  sessionList.addEventListener("scroll", closeRowMenu);
4956
+ // Nowhere in the sidebar is the browser's menu the one meant: a row answers
4957
+ // with its own above, and the space between rows answers with nothing rather
4958
+ // than with Back, Reload and View Source. Text fields keep theirs — paste.
4959
+ sidebarEl.addEventListener("contextmenu", (e) => {
4960
+ if (e.target.closest("input, textarea, [contenteditable]")) return;
4961
+ e.preventDefault();
4962
+ });
4418
4963
 
4419
4964
  function sessionRow(s, nested) {
4420
4965
  const item = el("div", "session" + (s.current ? " active" : "") + (nested ? " nested" : ""));
@@ -4469,7 +5014,17 @@ function sessionRow(s, nested) {
4469
5014
  more.addEventListener("click", (e) => {
4470
5015
  e.stopPropagation();
4471
5016
  if (menuRow === item) return closeRowMenu();
5017
+ openMenu(more);
5018
+ });
5019
+ // Right-click opens the row's own menu where the pointer is, instead of the
5020
+ // browser's, which has nothing to say about a session.
5021
+ item.addEventListener("contextmenu", (e) => {
5022
+ e.preventDefault();
5023
+ e.stopPropagation();
5024
+ openMenu(pointAnchor(e));
5025
+ });
4472
5026
 
5027
+ function openMenu(anchor) {
4473
5028
  // A transcript cannot be recovered, so the item asks once rather than
4474
5029
  // deleting on the click that opened the menu — but a modal for something
4475
5030
  // reached from a list row is heavier than what it protects against. The
@@ -4479,14 +5034,14 @@ function sessionRow(s, nested) {
4479
5034
  del.classList.add("armed");
4480
5035
  del.querySelector(".menu-label").textContent = t("session.deleteConfirm");
4481
5036
  rowMenu.replaceChildren(el("div", "menu-note", t("session.deleteNote")), del);
4482
- placeRowMenu(more);
5037
+ placeRowMenu(anchor);
4483
5038
  return;
4484
5039
  }
4485
5040
  closeRowMenu();
4486
5041
  void deleteSession(s.id);
4487
5042
  } });
4488
5043
 
4489
- openRowMenu(more, item, [
5044
+ openRowMenu(anchor, item, [
4490
5045
  menuItem(t(s.pinned ? "session.unpin" : "session.pin"), { key: "p", run: () => {
4491
5046
  closeRowMenu();
4492
5047
  void togglePin("session", s.id, !s.pinned);
@@ -4504,7 +5059,7 @@ function sessionRow(s, nested) {
4504
5059
  menuSep(),
4505
5060
  del,
4506
5061
  ]);
4507
- });
5062
+ }
4508
5063
  item.appendChild(more);
4509
5064
 
4510
5065
  item.addEventListener("click", () => {
@@ -4648,12 +5203,30 @@ async function loadModels() {
4648
5203
  const markFor = (id) =>
4649
5204
  markers.get(id) || ((extra) => providerMark(id, id, extra));
4650
5205
 
5206
+ // No context window here. It is a number you consult when choosing a model
5207
+ // once, not every time you pick one off a list you already know, and printed
5208
+ // down the right of four hundred rows it competes with the only thing the
5209
+ // row is for — the name. The panel and the provider dialog still carry it,
5210
+ // where a model is being chosen rather than switched to.
4651
5211
  const listed = models.map((m) => ({
4652
5212
  value: m.id,
4653
- label: m.id,
5213
+ // Without the provider: the group header above the row already names it,
5214
+ // and "official/" repeated down every row pushes the part that differs to
5215
+ // the right. An aggregator's own namespace stays — "~anthropic/…" under
5216
+ // OpenRouter is what tells its rows apart. The whole id is still what
5217
+ // search matches, so typing it as /model takes it works here too.
5218
+ label: m.id.indexOf(m.provider + "/") === 0 ? m.id.slice(m.provider.length + 1) : m.id,
5219
+ keywords: m.id,
4654
5220
  group: m.provider,
4655
5221
  mark: markFor(m.provider),
4656
- sub: m.contextWindow ? Math.round(m.contextWindow / 1000) + "k" : "",
5222
+ // The id is left whole even where it already ends in ":free". That suffix
5223
+ // is OpenRouter's routing variant and part of the string somebody types
5224
+ // into /model, and trimming it to save the repetition would render
5225
+ // minimax-m2.7 and minimax-m2.7:free as the same row — two models that
5226
+ // differ in what they cost, told apart by a badge alone. A vendor whose
5227
+ // free models are not named for it gets the badge and nothing else, which
5228
+ // is the case this exists for.
5229
+ ...(m.free ? { tag: t("models.free") } : {}),
4657
5230
  }));
4658
5231
 
4659
5232
  // A vendor with nothing to list is still an answer. Local servers and the
@@ -4675,10 +5248,14 @@ async function loadModels() {
4675
5248
  // nothing discovered yet landed under four hundred rows of the vendors that
4676
5249
  // had — which put the recommended plan, whose whole roster arrives only
4677
5250
  // after signing in, at the very bottom of the menu meant to sell it.
5251
+ // Within a vendor, the server's order: newest first by family, variants
5252
+ // after their model (model-order.ts). A string sort here put claude-opus-4-5
5253
+ // above claude-opus-5 and scattered :batch rows through the list.
5254
+ const rank = new Map(models.map((m, i) => [m.id, i]));
4678
5255
  modelOptions = listed.concat(stubs).sort(
4679
5256
  (a, b) =>
4680
5257
  (order.get(a.group) ?? 9999) - (order.get(b.group) ?? 9999) ||
4681
- a.value.localeCompare(b.value),
5258
+ (rank.get(a.value) ?? 0) - (rank.get(b.value) ?? 0),
4682
5259
  );
4683
5260
 
4684
5261
  const current = models.find((m) => m.current);
@@ -4937,7 +5514,21 @@ events.onmessage = (message) => {
4937
5514
  break;
4938
5515
  }
4939
5516
 
5517
+ // One event carrying the whole list rather than add/remove pairs: the
5518
+ // page redraws from it, so a dropped event cannot leave a chip behind for
5519
+ // a message that was sent ten minutes ago.
5520
+ case "queue":
5521
+ P.queued = event.queued || [];
5522
+ renderQueued();
5523
+ break;
5524
+
4940
5525
  case "assistant_message": P.streaming = null; break;
5526
+ case "turn_start": dropPendingTools(); break;
5527
+ case "tool_pending":
5528
+ sealThinking();
5529
+ pendingToolCard(event.toolCallId, event.toolName);
5530
+ startWorking(toolTitle(event.toolName));
5531
+ break;
4941
5532
  case "tool_start":
4942
5533
  sealThinking();
4943
5534
  toolCard(event.toolCallId, event.title, event.toolName, event.input);
@@ -5030,6 +5621,12 @@ events.onmessage = (message) => {
5030
5621
  addNotice(t("memory.extracted", { count: event.notes.length, list: event.notes.map((note) => note.summary).join("; ") }));
5031
5622
  break;
5032
5623
  case "notice": addNotice(event.message); break;
5624
+ // A routine ran: its session is new, and the Routines page, if open, has
5625
+ // a result to show.
5626
+ case "sessions":
5627
+ void loadSessions();
5628
+ if (shownPanel === "nav-routines") void showRoutines();
5629
+ break;
5033
5630
  case "error":
5034
5631
  P.streaming = null;
5035
5632
  // A 402 is not an error to print, it is a screen to open. What the
@@ -5048,6 +5645,7 @@ events.onmessage = (message) => {
5048
5645
  case "done": {
5049
5646
  sealThinking();
5050
5647
  stopWorking();
5648
+ dropPendingTools();
5051
5649
  // The fork point comes from the server: one reply can span several
5052
5650
  // stored messages, and a client-side count would drift the moment tool
5053
5651
  // results land in a message of their own.
@@ -5348,8 +5946,16 @@ function showAsk(event) {
5348
5946
  askState = { request, view, expanded: false, raw: false };
5349
5947
 
5350
5948
  paintAskHead(view, request);
5351
- askScope.textContent = "";
5352
- askScope.hidden = true;
5949
+ // Every description is laid out up front, one line each and stacked in one
5950
+ // cell, so hovering swaps the text without resizing the dialog. The rule key
5951
+ // closes each line: a long path is what gets cut, and the tooltip has it all.
5952
+ const ruleKey = askRuleKey(request);
5953
+ for (const line of askScope.children) {
5954
+ const scope = line.dataset.scope;
5955
+ line.textContent = t(scope === "once" ? "ask.scopeOnce" : scope === "session" ? "ask.scopeSession" : "ask.scopeProject", { key: ruleKey });
5956
+ line.title = line.textContent;
5957
+ }
5958
+ askScope.dataset.show = "once";
5353
5959
  renderAskBody();
5354
5960
  // The palette is a convenience; this is a turn waiting on an answer. Two
5355
5961
  // stacked modals would leave the deeper one unreachable behind the newer.
@@ -5412,15 +6018,10 @@ for (const button of ask.querySelectorAll("[data-answer]")) {
5412
6018
  const scope = button.dataset.scope;
5413
6019
  if (!scope) continue;
5414
6020
  const describe = () => {
5415
- if (!askState) return;
5416
- askScope.textContent = t(scope === "session" ? "ask.scopeSession" : "ask.scopeProject", {
5417
- key: askRuleKey(askState.request),
5418
- });
5419
- askScope.hidden = false;
6021
+ askScope.dataset.show = scope;
5420
6022
  };
5421
6023
  const hide = () => {
5422
- askScope.textContent = "";
5423
- askScope.hidden = true;
6024
+ askScope.dataset.show = "once";
5424
6025
  };
5425
6026
  button.addEventListener("mouseenter", describe);
5426
6027
  button.addEventListener("focus", describe);
@@ -5827,6 +6428,7 @@ function questionSettled(entry) {
5827
6428
  // stays small and a half-finished upload cannot start a turn.
5828
6429
 
5829
6430
  const attachEl = document.getElementById("attachments");
6431
+ const queuedEl = document.getElementById("queued");
5830
6432
  const fileInput = document.getElementById("file");
5831
6433
  const composerBox = document.getElementById("composer-box");
5832
6434
  let staged = [];
@@ -5847,6 +6449,52 @@ let refs = [];
5847
6449
  const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
5848
6450
  const refKey = (ref) => ref.kind + ":" + ref.value;
5849
6451
 
6452
+ /**
6453
+ * The messages waiting behind the running turn.
6454
+ *
6455
+ * Drawn from the pane rather than from a local list: the queue lives on the
6456
+ * server, so a reload and a second tab show the same thing, and an entry that
6457
+ * has just been sent disappears because the server said so.
6458
+ */
6459
+ function renderQueued() {
6460
+ queuedEl.replaceChildren();
6461
+ // The active pane, not the event-scoped P: this row sits in the composer,
6462
+ // and the composer belongs to the tab on screen even while a background
6463
+ // tab is the one streaming.
6464
+ const here = panes.get(activeId);
6465
+ const waiting = (here && here.queued) || [];
6466
+ for (const entry of waiting) {
6467
+ const row = el("div", "q" + (entry.soon ? " soon" : ""));
6468
+ // Two different promises: one joins the turn that is running, the other
6469
+ // waits for it to end. Saying "Queued" for both would misdescribe half.
6470
+ row.appendChild(el("span", "tag", t(entry.soon ? "queue.soon" : "queue.mark")));
6471
+ const names = (entry.attachments || []).join(", ");
6472
+ row.appendChild(el("span", "n", entry.text || names || t("queue.attachmentsOnly")));
6473
+ // Only on the ones that are waiting. A message already handed to the
6474
+ // running turn is as immediate as it gets, and a second button promising
6475
+ // to hurry it would be promising nothing.
6476
+ if (!entry.soon) {
6477
+ // Words, not an arrow. An arrow beside a × on a row that is already
6478
+ // about sending reads as "next" or "move"; the one thing it has to say
6479
+ // is that this message goes now.
6480
+ const now = el("button", "go", t("queue.sendNow"));
6481
+ now.type = "button";
6482
+ now.addEventListener("click", () => void api("/sendnow", { session: activeId, id: entry.id }));
6483
+ row.appendChild(now);
6484
+ }
6485
+ const drop = el("button", "x", "×");
6486
+ drop.type = "button";
6487
+ drop.title = t("queue.drop");
6488
+ drop.addEventListener("click", () => void api("/unqueue", { session: activeId, id: entry.id }));
6489
+ row.appendChild(drop);
6490
+ queuedEl.appendChild(row);
6491
+ }
6492
+ // Idle with something still waiting means the turn was stopped or failed —
6493
+ // the server will not restart a queue on its own after either. The send
6494
+ // button becomes the way to let it go.
6495
+ sendBtn.classList.toggle("has-queue", waiting.length > 0 && !(here && here.busy));
6496
+ }
6497
+
5850
6498
  function renderAttachments() {
5851
6499
  attachEl.replaceChildren();
5852
6500
 
@@ -6894,8 +7542,7 @@ input.addEventListener("keydown", (e) => {
6894
7542
  input.setSelectionRange(from, from);
6895
7543
  paintMirror();
6896
7544
  // The box may now fit in fewer lines, and the grower only runs on input.
6897
- input.style.height = "auto";
6898
- input.style.height = Math.min(Math.max(input.scrollHeight, 56), window.innerHeight * 0.32) + "px";
7545
+ autosize();
6899
7546
  });
6900
7547
 
6901
7548
  /**
@@ -6937,6 +7584,70 @@ function paintMirror() {
6937
7584
  if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
6938
7585
  }
6939
7586
 
7587
+ // --- the caret ------------------------------------------------------------
7588
+
7589
+ /**
7590
+ * Draw the caret, because the field cannot draw one thick enough.
7591
+ *
7592
+ * A textarea's caret is one device pixel and the only thing CSS offers is its
7593
+ * colour. On a pale field that bar is genuinely hard to find, so the native
7594
+ * one is hidden and this one is put where it would have been.
7595
+ *
7596
+ * Where that is comes from a probe: the same box as the mirror, with the same
7597
+ * font, width, padding and wrapping, holding the text up to the caret and
7598
+ * then a zero-width marker. Whatever rectangle the marker lands in is where
7599
+ * the field would have drawn the bar — including after a wrap, which is the
7600
+ * case arithmetic on character widths always gets wrong.
7601
+ */
7602
+ const caretEl = document.getElementById("caret");
7603
+ const caretProbe = document.getElementById("caret-probe");
7604
+ let caretRest;
7605
+
7606
+ function placeCaret() {
7607
+ // Only when the field has the caret and nothing is selected. A selection
7608
+ // draws its own edges, and a bar inside one reads as a second insertion
7609
+ // point.
7610
+ const showing =
7611
+ document.activeElement === input && input.selectionStart === input.selectionEnd;
7612
+ caretEl.classList.toggle("on", showing);
7613
+ if (!showing) return;
7614
+
7615
+ const head = input.value.slice(0, input.selectionStart);
7616
+ caretProbe.textContent = head;
7617
+ // A trailing newline collapses unless something follows it, which would put
7618
+ // the caret at the end of the line above rather than on the empty one.
7619
+ const marker = document.createElement("span");
7620
+ marker.textContent = String.fromCharCode(0x200b);
7621
+ caretProbe.appendChild(marker);
7622
+
7623
+ // The marker's offsets are inside the probe; the caret is placed against
7624
+ // the wrapper they share. The probe is inset and padded so the mention chip
7625
+ // has somewhere to bleed — read those numbers off it rather than repeating
7626
+ // them here, or the caret drifts the day the padding changes. Scrolling is
7627
+ // the field's, not the probe's.
7628
+ // The line box, which is what the field's own caret fills. Nothing here is
7629
+ // trying to improve on the native geometry — the only reason this caret
7630
+ // exists is that CSS offers no way to make that one wider than a pixel.
7631
+ caretEl.style.left = caretProbe.offsetLeft + marker.offsetLeft + "px";
7632
+ caretEl.style.top = caretProbe.offsetTop + marker.offsetTop - input.scrollTop + "px";
7633
+ caretEl.style.height = marker.offsetHeight + "px";
7634
+
7635
+ // Solid while it is moving, blinking once it settles.
7636
+ caretEl.classList.add("moving");
7637
+ clearTimeout(caretRest);
7638
+ caretRest = setTimeout(() => caretEl.classList.remove("moving"), 420);
7639
+ }
7640
+
7641
+ // Every way the caret can move. The selectionchange event covers the keyboard
7642
+ // and the mouse both, and fires for a textarea in every browser this ships to.
7643
+ for (const event of ["input", "focus", "blur", "scroll"]) {
7644
+ input.addEventListener(event, placeCaret);
7645
+ }
7646
+ document.addEventListener("selectionchange", () => {
7647
+ if (document.activeElement === input) placeCaret();
7648
+ });
7649
+ window.addEventListener("resize", placeCaret);
7650
+
6940
7651
  /** How wide the field draws an @ sign: the slot the icon has to fit in. */
6941
7652
  function atWidth() {
6942
7653
  const probe = document.createElement("canvas").getContext("2d");
@@ -6975,6 +7686,17 @@ const COMMANDS = [
6975
7686
  return void api("/model", { model: arg }).then(pushNotice);
6976
7687
  },
6977
7688
  },
7689
+ {
7690
+ name: "login",
7691
+ arg: "",
7692
+ hint: () => t("cmd.login"),
7693
+ // The same dialog the sidebar's sign-in row opens, so every way in — the
7694
+ // website and the emailed code — is on screen.
7695
+ run: async () => {
7696
+ const account = await getJson("/account");
7697
+ openConnect((account && account.provider) || "official");
7698
+ },
7699
+ },
6978
7700
  {
6979
7701
  name: "mode",
6980
7702
  arg: "mode",
@@ -7192,8 +7914,9 @@ document.getElementById("composer").addEventListener("submit", (e) => {
7192
7914
  // are sent as prose, because /etc and and/or are things people write.
7193
7915
  if (runCommand(text)) {
7194
7916
  input.value = "";
7195
- input.style.height = "auto";
7196
- closeAttachMenu();
7917
+ autosize();
7918
+ paintMirror();
7919
+ closeAttachMenu();
7197
7920
  return;
7198
7921
  }
7199
7922
  // A question waiting in this tab takes what is typed as its answer. People
@@ -7202,12 +7925,19 @@ document.getElementById("composer").addEventListener("submit", (e) => {
7202
7925
  const waiting = openQuestion(activeId);
7203
7926
  if (waiting && text && !staged.length) {
7204
7927
  input.value = "";
7205
- input.style.height = "auto";
7928
+ autosize();
7206
7929
  paintMirror();
7207
7930
  void sendQuestion(waiting, { outcome: "dismissed", note: text });
7208
7931
  return;
7209
7932
  }
7210
- if (!text && !staged.length && !refs.length) return;
7933
+ if (!text && !staged.length && !refs.length) {
7934
+ // Send on an empty composer normally does nothing. With messages left
7935
+ // waiting — the turn under them was stopped, or it failed — it is the
7936
+ // release: the server does not restart a queue after either by itself.
7937
+ const here = panes.get(activeId);
7938
+ if (here && !here.busy && (here.queued || []).length) void api("/flush", { session: activeId });
7939
+ return;
7940
+ }
7211
7941
  const ids = staged.map((file) => file.id);
7212
7942
  const shots = staged
7213
7943
  .filter((file) => file.preview)
@@ -7215,7 +7945,7 @@ document.getElementById("composer").addEventListener("submit", (e) => {
7215
7945
  if (shots.length) sentPreviews.set(activeId, shots);
7216
7946
  const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
7217
7947
  input.value = "";
7218
- input.style.height = "auto";
7948
+ autosize();
7219
7949
  paintMirror();
7220
7950
  staged = [];
7221
7951
  refs = [];
@@ -7237,15 +7967,92 @@ input.addEventListener("keydown", (e) => {
7237
7967
  }
7238
7968
  });
7239
7969
  input.addEventListener("input", () => {
7240
- // Reset first so the box can shrink as well as grow, then clamp. The floor
7241
- // matches the CSS min-height, or deleting a long paste would leave the box
7242
- // taller than an empty one.
7243
- input.style.height = "auto";
7244
- const wanted = Math.max(input.scrollHeight, 56);
7245
- input.style.height = Math.min(wanted, window.innerHeight * 0.32) + "px";
7970
+ autosize();
7246
7971
  paintMirror();
7972
+ placeCaret();
7247
7973
  });
7248
7974
 
7975
+ // --- the field's height ---------------------------------------------------
7976
+
7977
+ /**
7978
+ * How tall the field is: grown to fit what is typed, or what it was dragged to.
7979
+ *
7980
+ * FIELD_MIN matches the CSS floor, so deleting a long paste cannot leave the
7981
+ * box taller than an empty one. The ceilings are a share of the window rather
7982
+ * than fixed numbers: the field must not swallow the conversation on a short
7983
+ * screen. Growing on its own stops at a third of the window; a person dragging
7984
+ * is allowed most of it, because they can see what they are giving up.
7985
+ */
7986
+ const FIELD_MIN = 56;
7987
+ const fieldGrowMax = () => window.innerHeight * 0.32;
7988
+ const fieldDragMax = () => Math.max(FIELD_MIN, window.innerHeight * 0.7);
7989
+ const FIELD_KEY = "oharness.composerHeight";
7990
+
7991
+ /** Set by dragging the grip; null means the field sizes itself. */
7992
+ let fieldHeight = null;
7993
+ try {
7994
+ const saved = Number(localStorage.getItem(FIELD_KEY));
7995
+ if (Number.isFinite(saved) && saved >= FIELD_MIN) fieldHeight = saved;
7996
+ } catch { /* private window, or storage turned off */ }
7997
+
7998
+ function autosize() {
7999
+ if (fieldHeight !== null) {
8000
+ // A dragged height wins over the CSS ceiling, which is there to bound the
8001
+ // field's own growing rather than to overrule the person.
8002
+ input.style.maxHeight = "none";
8003
+ input.style.height = Math.min(fieldHeight, fieldDragMax()) + "px";
8004
+ return;
8005
+ }
8006
+ input.style.maxHeight = "";
8007
+ // Reset first so the box can shrink as well as grow, then clamp.
8008
+ input.style.height = "auto";
8009
+ input.style.height = Math.min(Math.max(input.scrollHeight, FIELD_MIN), fieldGrowMax()) + "px";
8010
+ }
8011
+
8012
+ function setFieldHeight(px) {
8013
+ fieldHeight = px === null ? null : Math.round(Math.min(Math.max(px, FIELD_MIN), fieldDragMax()));
8014
+ try {
8015
+ if (fieldHeight === null) localStorage.removeItem(FIELD_KEY);
8016
+ else localStorage.setItem(FIELD_KEY, String(fieldHeight));
8017
+ } catch { /* nothing to do: the height still applies for this page */ }
8018
+ autosize();
8019
+ }
8020
+
8021
+ const grip = document.getElementById("grip");
8022
+ grip.addEventListener("pointerdown", (e) => {
8023
+ // Only the primary button, and never a second drag inside the first.
8024
+ if (e.button !== 0) return;
8025
+ e.preventDefault();
8026
+ const startY = e.clientY;
8027
+ const startHeight = input.getBoundingClientRect().height;
8028
+ // Capture is the nicety; the window listeners below are what make the drag
8029
+ // work. The strip is twelve pixels tall, and a pointer moving faster than
8030
+ // the box can follow leaves it within a frame.
8031
+ try { grip.setPointerCapture(e.pointerId); } catch { /* no live pointer to capture */ }
8032
+ grip.classList.add("dragging");
8033
+ document.body.classList.add("resizing");
8034
+
8035
+ // Up is taller: the box grows from its top edge, which is the edge in hand.
8036
+ const move = (ev) => setFieldHeight(startHeight + (startY - ev.clientY));
8037
+ const done = () => {
8038
+ window.removeEventListener("pointermove", move);
8039
+ window.removeEventListener("pointerup", done);
8040
+ window.removeEventListener("pointercancel", done);
8041
+ grip.classList.remove("dragging");
8042
+ document.body.classList.remove("resizing");
8043
+ };
8044
+ window.addEventListener("pointermove", move);
8045
+ window.addEventListener("pointerup", done);
8046
+ window.addEventListener("pointercancel", done);
8047
+ });
8048
+ // Back to sizing itself. A drag is easy to do by accident on a trackpad, and
8049
+ // without this the only way back is to guess the original height.
8050
+ grip.addEventListener("dblclick", () => setFieldHeight(null));
8051
+ // A dragged height is kept, but never past the ceiling of a window that has
8052
+ // since been made shorter.
8053
+ window.addEventListener("resize", () => autosize());
8054
+ autosize();
8055
+
7249
8056
  // --- the mention chip under the text ------------------------------------
7250
8057
 
7251
8058
 
@@ -8370,6 +9177,11 @@ async function showModels() {
8370
9177
  if (model.contextWindow) {
8371
9178
  chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
8372
9179
  }
9180
+ // Only ever on a model whose vendor quoted zero for both halves.
9181
+ // The server's isFree is the whole of the decision, so a chip without
9182
+ // this is a model nobody has said is free, not one that is known to
9183
+ // cost something.
9184
+ if (model.free) chip.appendChild(el("span", "free", t("models.free")));
8373
9185
  chip.addEventListener("click", () => {
8374
9186
  track("model_switched", { model: model.id, via: "panel" });
8375
9187
  void api("/model", { session: activeId, id: model.id }).then(async (state) => {
@@ -8551,9 +9363,33 @@ function openConnect(providerId, thenModel) {
8551
9363
  field.autocomplete = "off";
8552
9364
  field.spellcheck = false;
8553
9365
 
8554
- const hint = el("div", "cn-note");
9366
+ const hint = el("div", "cn-note cn-tip");
8555
9367
  hint.hidden = true;
9368
+ hint.setAttribute("role", "tooltip");
8556
9369
  const why = document.getElementById("cn-why");
9370
+ // In the dialog's column rather than its body, so it floats over the body
9371
+ // instead of taking a place in it. Left from the last opening otherwise.
9372
+ const main = why.closest(".cn-main");
9373
+ for (const old of main.querySelectorAll(":scope > .cn-tip")) old.remove();
9374
+ main.appendChild(hint);
9375
+
9376
+ // A third-party key is the one people hesitate over pasting into a new app,
9377
+ // so the dialog says, in plain sight, where it goes and what does not happen
9378
+ // to it. Not for OHarness's own key, which is ours to issue. And not
9379
+ // "secure": it is a plain file only this account can read, and claiming
9380
+ // more would promise something the file does not do.
9381
+ const thirdParty = provider.id !== "official";
9382
+ // One line, the fact in bold. The path and the file mode are behind the
9383
+ // mark with the rest of the detail, for whoever wants to check.
9384
+ // Who the key goes to, by name: "sent only to Together AI" is a claim a
9385
+ // person can check against the address the request goes to, where "stays
9386
+ // on this computer" alone is not quite true — the provider has to see it.
9387
+ const keptLocal = el("div", "cn-note cn-local");
9388
+ keptLocal.appendChild(svgIcon("lock"));
9389
+ const words = el("span");
9390
+ words.appendChild(el("strong", null, t("models.keyLocalLead")));
9391
+ words.appendChild(document.createTextNode(t("models.keyLocalRest", { provider: provider.label || provider.id })));
9392
+ keptLocal.appendChild(words);
8557
9393
 
8558
9394
  // Hovering shows it, clicking keeps it. Peeking is the common case and a
8559
9395
  // click to see two lines and another to put them away is two clicks more
@@ -8659,6 +9495,7 @@ function openConnect(providerId, thenModel) {
8659
9495
  docs.hidden = true;
8660
9496
  }
8661
9497
  why.hidden = mode !== "key";
9498
+ keptLocal.hidden = !(mode === "key" && thirdParty);
8662
9499
  // Hidden rather than left out, so switching modes does not rebuild a
8663
9500
  // button whose click may already have a sign-in running behind it.
8664
9501
  site.hidden = mode !== "account";
@@ -8827,7 +9664,7 @@ function openConnect(providerId, thenModel) {
8827
9664
  site,
8828
9665
  orCode,
8829
9666
  field,
8830
- hint,
9667
+ keptLocal,
8831
9668
  );
8832
9669
  // Re-attached on every open: the actions row is markup, so a link left from
8833
9670
  // the last vendor would otherwise point at their key page. The link is built
@@ -9836,6 +10673,7 @@ function openProviderPicker(rest, byProvider) {
9836
10673
  if (model.contextWindow) {
9837
10674
  chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
9838
10675
  }
10676
+ if (model.free) chip.appendChild(el("span", "free", t("models.free")));
9839
10677
  chip.addEventListener("click", (e) => {
9840
10678
  e.preventDefault();
9841
10679
  e.stopPropagation();
@@ -9903,6 +10741,71 @@ document.getElementById("nav-models").addEventListener("click", () => {
9903
10741
 
9904
10742
  // --- routines and settings -------------------------------------------------
9905
10743
 
10744
+ /**
10745
+ * Start a routine by describing it.
10746
+ *
10747
+ * A fresh session with the sentence begun, rather than a form: "every weekday
10748
+ * morning, summarise yesterday's commits" is how people say it, and the agent
10749
+ * turns that into a cron and a prompt that stands on its own. A form asked for
10750
+ * five cron fields to express the same thing.
10751
+ */
10752
+ async function newRoutine() {
10753
+ await switchTo({ action: "new" });
10754
+ input.value = t("routines.starter");
10755
+ paintMirror();
10756
+ input.focus();
10757
+ input.setSelectionRange(input.value.length, input.value.length);
10758
+ }
10759
+
10760
+ /** One run as a line: when, how it went, and what it cost. */
10761
+ function runSummary(run) {
10762
+ const when = new Date(run.startedAt).toLocaleString();
10763
+ if (run.skipped) return t("routines.skipped", { when });
10764
+ const parts = [t(run.ok ? "routines.lastOk" : "routines.lastFailed", { when })];
10765
+ if (run.catchUp) parts.push(t("routines.catchUp"));
10766
+ parts.push(t("routines.turns", { n: run.turns }));
10767
+ if (run.costUsd) parts.push("$" + run.costUsd.toFixed(4));
10768
+ return parts.join(" · ");
10769
+ }
10770
+
10771
+ /**
10772
+ * A routine's past runs, fetched when opened.
10773
+ *
10774
+ * Skipped times are listed with the runs, with why: a routine that "didn't
10775
+ * run" is the question this answers, and the answer is usually that the
10776
+ * previous run was still going. A run opens its session — the whole of what
10777
+ * the agent did, not the last paragraph of it.
10778
+ */
10779
+ function routineHistory(routine) {
10780
+ const box = el("details", "routine-history");
10781
+ box.appendChild(el("summary", "muted small", t("routines.history")));
10782
+ const list = el("div", "routine-runs");
10783
+ box.appendChild(list);
10784
+ box.addEventListener("toggle", () => {
10785
+ if (!box.open || list.dataset.loaded) return;
10786
+ list.dataset.loaded = "1";
10787
+ list.appendChild(el("div", "muted small", t("routines.loading")));
10788
+ void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
10789
+ list.replaceChildren();
10790
+ const runs = (body && body.runs) || [];
10791
+ if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
10792
+ for (const run of runs) {
10793
+ const line = el(run.sessionId ? "button" : "div", "routine-run small" + (run.ok || run.skipped ? "" : " failed"));
10794
+ line.appendChild(el("span", null, runSummary(run)));
10795
+ if (run.skipped && run.reason === "still-running") {
10796
+ line.appendChild(el("span", "muted", t("routines.stillRunning")));
10797
+ }
10798
+ if (run.sessionId) {
10799
+ line.type = "button";
10800
+ line.addEventListener("click", () => void openSession(run.sessionId, routine.cwd));
10801
+ }
10802
+ list.appendChild(line);
10803
+ }
10804
+ });
10805
+ });
10806
+ return box;
10807
+ }
10808
+
9906
10809
  /**
9907
10810
  * Scheduled runs, read from the scheduler rather than kept here.
9908
10811
  *
@@ -9917,41 +10820,88 @@ async function showRoutines() {
9917
10820
  const routines = body.routines;
9918
10821
 
9919
10822
  const panel = el("div", "panel");
9920
- panel.appendChild(el("h2", null, t("routines.title")));
10823
+ const top = el("div", "row-head");
10824
+ top.appendChild(el("h2", null, t("routines.title")));
10825
+ const addButton = el("button", "ghost", t("routines.add"));
10826
+ addButton.type = "button";
10827
+ top.appendChild(addButton);
10828
+ panel.appendChild(top);
10829
+
10830
+ addButton.addEventListener("click", () => void newRoutine());
9921
10831
 
9922
10832
  if (!routines || !routines.length) {
9923
10833
  panel.appendChild(el("p", "muted", t("routines.none")));
9924
- panel.appendChild(el("p", "muted",
9925
- "Add them under \"schedules\" in oharness.config.json — a name, a five-field cron, and the prompt each firing runs."));
10834
+ panel.appendChild(el("p", "muted", t("routines.empty")));
9926
10835
  } else {
9927
10836
  for (const routine of routines) {
9928
10837
  const row = el("div", "row-card");
9929
10838
  const head = el("div", "row-head");
9930
10839
  head.appendChild(el("span", "row-title", routine.name));
9931
10840
  head.appendChild(el("span", "mono row-cron", routine.cron));
9932
- if (!routine.enabled) head.appendChild(el("span", "tag", "disabled"));
10841
+ if (!routine.enabled) head.appendChild(el("span", "tag", t("routines.paused")));
10842
+ // Where it runs. A routine is made in one project and runs there, from
10843
+ // whichever project is open when its time comes, so the card says which.
10844
+ if (routine.cwd) {
10845
+ const where = el("span", "muted small routine-where", projectName(routine.cwd));
10846
+ where.title = routine.cwd;
10847
+ head.appendChild(where);
10848
+ }
9933
10849
 
9934
10850
  const run = el("button", "ghost", t("routines.run"));
9935
10851
  run.type = "button";
9936
10852
  run.addEventListener("click", () => {
9937
10853
  run.disabled = true;
9938
10854
  run.textContent = t("routines.running");
9939
- void api("/routine", { name: routine.name }).then(() => showRoutines());
10855
+ void api("/routine", { name: routine.name }).then((result) => {
10856
+ pushNotice(result);
10857
+ void showRoutines();
10858
+ });
9940
10859
  });
9941
10860
  head.appendChild(run);
10861
+ if (routine.removable) {
10862
+ // Paused rather than deleted: the prompt someone got right is kept,
10863
+ // and a routine that should not run this week is one switch away from
10864
+ // running again.
10865
+ const toggle = el("button", "switch");
10866
+ toggle.type = "button";
10867
+ toggle.setAttribute("role", "switch");
10868
+ toggle.setAttribute("aria-checked", routine.enabled ? "true" : "false");
10869
+ const label = routine.enabled ? t("routines.pause") : t("routines.resume");
10870
+ toggle.setAttribute("aria-label", label);
10871
+ toggle.title = label;
10872
+ toggle.addEventListener("click", () => {
10873
+ toggle.disabled = true;
10874
+ void api("/routine/enabled", { name: routine.name, enabled: !routine.enabled }).then((result) => {
10875
+ pushNotice(result);
10876
+ void showRoutines();
10877
+ });
10878
+ });
10879
+ head.appendChild(toggle);
10880
+
10881
+ const remove = el("button", "ghost", t("routines.remove"));
10882
+ remove.type = "button";
10883
+ remove.addEventListener("click", () => {
10884
+ if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
10885
+ remove.disabled = true;
10886
+ void api("/routine/remove", { name: routine.name }).then((result) => {
10887
+ pushNotice(result);
10888
+ void showRoutines();
10889
+ });
10890
+ });
10891
+ head.appendChild(remove);
10892
+ }
9942
10893
  row.appendChild(head);
9943
10894
 
9944
10895
  const bits = [];
9945
- if (routine.nextRunAt) bits.push("next " + new Date(routine.nextRunAt).toLocaleString());
9946
- if (routine.lastRun) {
9947
- bits.push((routine.lastRun.ok ? "last ok" : "last failed") +
9948
- " · " + routine.lastRun.turns + " turns" +
9949
- (routine.lastRun.costUsd ? " · $" + routine.lastRun.costUsd.toFixed(4) : ""));
10896
+ if (routine.enabled && routine.nextRunAt) {
10897
+ bits.push(t("routines.next", { when: new Date(routine.nextRunAt).toLocaleString() }));
9950
10898
  }
10899
+ if (routine.lastRun) bits.push(runSummary(routine.lastRun));
9951
10900
  if (bits.length) row.appendChild(el("div", "muted small", bits.join(" · ")));
9952
10901
  if (routine.lastRun && routine.lastRun.error) {
9953
10902
  row.appendChild(el("div", "err small", routine.lastRun.error));
9954
10903
  }
10904
+ row.appendChild(routineHistory(routine));
9955
10905
  panel.appendChild(row);
9956
10906
  }
9957
10907
  }
@@ -10445,6 +11395,20 @@ async function showSettings() {
10445
11395
  }),
10446
11396
  );
10447
11397
  }
11398
+ // Only in the desktop app: keeping a computer awake is an Electron call, and
11399
+ // a switch a browser tab cannot honour would be one that lies.
11400
+ const keepAwake = await getJson("/keep-awake");
11401
+ if (keepAwake && !keepAwake.error && keepAwake.available) {
11402
+ target = groups.workspace;
11403
+ field(
11404
+ t("settings.keepAwake"),
11405
+ t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
11406
+ t("settings.keepAwakeHint"),
11407
+ toggle(keepAwake.enabled, t("settings.keepAwake"), (next) => {
11408
+ void api("/keep-awake", { enabled: next }).then(showSettings);
11409
+ }),
11410
+ );
11411
+ }
10448
11412
  const sections = [
10449
11413
  ["settings.section.workspace", [groups.workspace]],
10450
11414
  ["settings.section.sync", [groups.sync, groups.memory]],
@@ -10522,25 +11486,49 @@ async function showPermissions() {
10522
11486
  };
10523
11487
 
10524
11488
  // The mode first, because it decides what the rules below are exceptions to.
11489
+ // Drawn on the scale it sits on, so "workspace-write" reads as the middle
11490
+ // of three rather than as a word to look up.
11491
+ const modes = ["read-only", "workspace-write", "full-access"];
10525
11492
  const modeCard = card(t("perms.mode"));
10526
- modeCard.appendChild(el("div", "mono", workspace.mode || "—"));
11493
+ const modeRow = el("div", "perm-mode");
11494
+ const current = workspace.mode || "";
11495
+ modeRow.appendChild(el("span", "perm-mode-name " + current, current || "—"));
11496
+ if (modes.indexOf(current) >= 0) {
11497
+ const steps = el("div", "perm-mode-steps");
11498
+ for (const mode of modes) steps.appendChild(el("span", "perm-mode-step" + (mode === current ? " on" : ""), mode));
11499
+ modeRow.appendChild(steps);
11500
+ }
11501
+ modeCard.appendChild(modeRow);
11502
+ if (modes.indexOf(current) >= 0) modeCard.appendChild(el("div", "muted small", t("perms.modeHint." + current)));
10527
11503
 
10528
11504
  const rulesCard = card(t("settings.rules"));
10529
11505
  rulesCard.appendChild(el("div", "muted small", t("settings.rulesHint")));
11506
+ const group = (kind, title, items) => {
11507
+ const box = el("div", "perm-group");
11508
+ const head = el("div", "perm-group-head " + kind, title);
11509
+ head.appendChild(el("span", "n", String(items.length)));
11510
+ box.appendChild(head);
11511
+ const chips = el("div", "perm-chips");
11512
+ for (const item of items) {
11513
+ const chip = el("span", "perm-chip " + kind, item);
11514
+ chip.title = item;
11515
+ chips.appendChild(chip);
11516
+ }
11517
+ box.appendChild(chips);
11518
+ rulesCard.appendChild(box);
11519
+ };
10530
11520
  let any = false;
10531
11521
  for (const kind of ["allow", "ask", "deny"]) {
10532
11522
  if (!rules[kind] || !rules[kind].length) continue;
10533
11523
  any = true;
10534
- rulesCard.appendChild(listing(kind + " · " + rules[kind].length, rules[kind].join("\n")));
11524
+ group(kind, t("perms.kind." + kind), rules[kind]);
10535
11525
  }
10536
11526
  const session = rules.sessionAllow || [];
10537
11527
  if (session.length) {
10538
11528
  any = true;
10539
- rulesCard.appendChild(listing(
10540
- t("settings.sessionApproved") + " — " + t("settings.notPersisted"),
10541
- session.join("\n")));
11529
+ group("session", t("settings.sessionApproved") + " — " + t("settings.notPersisted"), session);
10542
11530
  }
10543
- if (!any) rulesCard.appendChild(el("div", "muted small", t("perms.noRules")));
11531
+ if (!any) rulesCard.appendChild(el("div", "perm-empty", t("perms.noRules")));
10544
11532
 
10545
11533
  // The roots, and only where they mean anything: in every other mode the
10546
11534
  // list is not consulted, and an editor for it would be a control that does
@@ -10551,6 +11539,7 @@ async function showPermissions() {
10551
11539
  const derived = all.filter((root) => chosen.indexOf(root) < 0);
10552
11540
 
10553
11541
  const writes = card(t("perms.writes"));
11542
+ writes.previousSibling.appendChild(el("span", "perm-count", String(all.length)));
10554
11543
  writes.appendChild(el("div", "muted small", t("perms.writesHint", { n: all.length })));
10555
11544
 
10556
11545
  // Yours, and editable. A textarea rather than a list with buttons because
@@ -10586,12 +11575,25 @@ async function showPermissions() {
10586
11575
  if (derived.length) {
10587
11576
  // "more" only reads right when there were some above it. With nothing
10588
11577
  // configured — the common case — every one of them is derived.
10589
- writes.appendChild(listing(
10590
- chosen.length
10591
- ? t("perms.derivedMore", { n: derived.length })
10592
- : t("perms.derivedAll", { n: derived.length }),
10593
- derived.join("\n"),
10594
- true));
11578
+ const group = el("div", "setting-list");
11579
+ group.appendChild(el("div", "muted small", chosen.length
11580
+ ? t("perms.derivedMore", { n: derived.length })
11581
+ : t("perms.derivedAll", { n: derived.length })));
11582
+ // The last segment is what tells fifty plugin paths apart; the long
11583
+ // shared prefix is dimmed and gives way first when the line is short.
11584
+ const list = el("div", "perm-roots");
11585
+ for (const root of derived) {
11586
+ const cut = root.replace(/[\\/]+$/, "").search(/[^\\/]+$/);
11587
+ const line = el("div", "perm-root");
11588
+ line.title = root;
11589
+ const dir = el("span", "dir");
11590
+ dir.appendChild(el("bdi", null, cut > 0 ? root.slice(0, cut) : ""));
11591
+ line.appendChild(dir);
11592
+ line.appendChild(el("span", "base", cut > 0 ? root.slice(cut) : root));
11593
+ list.appendChild(line);
11594
+ }
11595
+ group.appendChild(list);
11596
+ writes.appendChild(group);
10595
11597
  }
10596
11598
  }
10597
11599
  }
@@ -10657,25 +11659,48 @@ function auditKind(row) {
10657
11659
  * it does not place anything at all.
10658
11660
  */
10659
11661
  function auditList(rows, withDate) {
10660
- const list = el("div", "mono small audit-list");
11662
+ const list = el("div", "audit-list");
11663
+ let day = "";
10661
11664
  for (const row of rows) {
10662
11665
  const entry = row.entry;
10663
- const line = el("div", "trail-line");
10664
11666
  const at = new Date(entry.at);
10665
- const when = withDate
10666
- ? at.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + at.toLocaleTimeString()
10667
- : at.toLocaleTimeString();
10668
- const outcome = auditOutcome(row);
10669
- const mark = el("span", auditKind(row) === "denied" ? "err" : null, outcome);
10670
- line.appendChild(el("span", "muted", when + " "));
10671
- line.appendChild(mark);
10672
- line.appendChild(document.createTextNode(" " + (entry.principal || entry.tool)));
10673
- const notes = [];
10674
- if (entry.rule) notes.push(entry.rule);
10675
- if (row.answer && row.answer.remembered) notes.push("remembered " + row.answer.remembered);
11667
+ // Across sessions the date is a heading rather than a column: the same
11668
+ // "Sep 15" forty times over is noise that pushes the tool off the line.
11669
+ if (withDate) {
11670
+ const label = at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
11671
+ if (label !== day) {
11672
+ day = label;
11673
+ list.appendChild(el("div", "audit-day", label));
11674
+ }
11675
+ }
11676
+ const kind = auditKind(row);
11677
+ const line = el("div", "trail-line audit-row " + kind);
11678
+ line.appendChild(el("span", "audit-time", at.toLocaleTimeString()));
11679
+ line.appendChild(el("span", "audit-pill " + kind, auditOutcome(row)));
11680
+
11681
+ // "Edit(/a/b/c.md)" reads as a tool and what it touched; drawn as one
11682
+ // string the path is as loud as the verb.
11683
+ const label = entry.principal || entry.tool;
11684
+ const what = el("span", "audit-what");
11685
+ const open = label.indexOf("(");
11686
+ if (open > 0 && label.endsWith(")")) {
11687
+ what.appendChild(el("span", "audit-tool", label.slice(0, open)));
11688
+ const arg = el("span", "audit-arg", label.slice(open + 1, -1));
11689
+ arg.title = label.slice(open + 1, -1);
11690
+ what.appendChild(arg);
11691
+ } else {
11692
+ what.appendChild(el("span", "audit-tool", label));
11693
+ }
11694
+ line.appendChild(what);
11695
+
11696
+ const notes = el("span", "audit-notes");
11697
+ if (entry.rule) notes.appendChild(el("span", "audit-tag", entry.rule));
11698
+ if (row.answer && row.answer.remembered) {
11699
+ notes.appendChild(el("span", "audit-tag remembered", "remembered " + row.answer.remembered));
11700
+ }
10676
11701
  // An ask with no answer: the process stopped while the question was up.
10677
- if (entry.behavior === "ask" && !row.answer) notes.push("unanswered");
10678
- if (notes.length) line.appendChild(el("span", "muted", " " + notes.join(" · ")));
11702
+ if (entry.behavior === "ask" && !row.answer) notes.appendChild(el("span", "audit-tag unanswered", "unanswered"));
11703
+ line.appendChild(notes);
10679
11704
  list.appendChild(line);
10680
11705
  }
10681
11706
  return list;
@@ -10754,7 +11779,14 @@ async function showAudit() {
10754
11779
  const seg = (options, chosen, onPick) => {
10755
11780
  const box = el("div", "seg");
10756
11781
  for (const option of options) {
10757
- const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""), option.label);
11782
+ const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""));
11783
+ if (option.color) {
11784
+ const dot = el("span", "seg-dot");
11785
+ dot.style.background = option.color;
11786
+ button.appendChild(dot);
11787
+ }
11788
+ button.appendChild(document.createTextNode(option.label));
11789
+ if (option.count !== undefined) button.appendChild(el("span", "seg-count", String(option.count)));
10758
11790
  button.type = "button";
10759
11791
  button.addEventListener("click", () => {
10760
11792
  if (option.id === chosen()) return;
@@ -10773,8 +11805,15 @@ async function showAudit() {
10773
11805
  (id) => { auditScope = id; void showAudit(); },
10774
11806
  ));
10775
11807
  const filters = ["all", "allowed", "denied", "unanswered"];
11808
+ const rows = auditRows(body.entries);
11809
+ const tally = { all: rows.length, allowed: 0, denied: 0, unanswered: 0 };
11810
+ for (const row of rows) tally[auditKind(row)] += 1;
11811
+ const dots = { allowed: "var(--ok)", denied: "var(--err)", unanswered: "var(--warn)" };
10776
11812
  const filterSeg = seg(
10777
- filters.map((id) => ({ id, label: t("audit.filter." + id) })),
11813
+ filters.map((id) => ({
11814
+ id, label: t("audit.filter." + id), count: tally[id],
11815
+ ...(dots[id] ? { color: dots[id] } : {}),
11816
+ })),
10778
11817
  () => auditFilter,
10779
11818
  (id) => {
10780
11819
  auditFilter = id;
@@ -10787,7 +11826,6 @@ async function showAudit() {
10787
11826
  controls.appendChild(filterSeg);
10788
11827
  panel.appendChild(controls);
10789
11828
 
10790
- const rows = auditRows(body.entries);
10791
11829
  const count = el("div", "muted small");
10792
11830
  panel.appendChild(count);
10793
11831
  const listBox = el("div");
@@ -12221,6 +13259,24 @@ async function loadProjects() {
12221
13259
  if (sessionRows.length) paintSessions();
12222
13260
  }
12223
13261
 
13262
+ async function renameProjectUi(cwd, name) {
13263
+ const next = window.prompt(t("projects.renamePrompt"), name);
13264
+ if (!next || next.trim() === name) return;
13265
+ const body = await api("/project", { path: cwd, rename: next.trim() });
13266
+ if (body && body.error) return void addNotice(body.error, "err");
13267
+ addNotice(t("projects.renamed", { name: body.project.name }));
13268
+ // Paths key tabs, local open/closed state and drag order. Reloading after
13269
+ // the server has rebuilt the renamed harness drops every stale path at once.
13270
+ window.location.reload();
13271
+ }
13272
+
13273
+ function exportProjectUi(cwd) {
13274
+ const link = document.createElement("a");
13275
+ link.href = q("/project/export?path=" + encodeURIComponent(cwd));
13276
+ link.download = "";
13277
+ link.click();
13278
+ }
13279
+
12224
13280
  function openProjectPicker() {
12225
13281
  const dialog = document.getElementById("project-picker");
12226
13282
  const field = document.getElementById("proj-search");
@@ -12247,6 +13303,25 @@ function openProjectPicker() {
12247
13303
 
12248
13304
  const line = el("div", "proj-row");
12249
13305
  line.appendChild(row);
13306
+ const rename = el("button", "proj-forget", "✎");
13307
+ rename.type = "button";
13308
+ rename.title = t("projects.rename");
13309
+ rename.setAttribute("aria-label", t("projects.rename") + ": " + project.name);
13310
+ rename.addEventListener("click", (e) => {
13311
+ e.stopPropagation();
13312
+ dialog.close();
13313
+ void renameProjectUi(project.path, project.name);
13314
+ });
13315
+ line.appendChild(rename);
13316
+ const archive = el("button", "proj-forget", "↓");
13317
+ archive.type = "button";
13318
+ archive.title = t("projects.export");
13319
+ archive.setAttribute("aria-label", t("projects.export") + ": " + project.name);
13320
+ archive.addEventListener("click", (e) => {
13321
+ e.stopPropagation();
13322
+ exportProjectUi(project.path);
13323
+ });
13324
+ line.appendChild(archive);
12250
13325
  // Not offered for the project this window is standing in: the next
12251
13326
  // switch calls addProject and puts it straight back, so the control
12252
13327
  // would appear to do nothing. Nothing recorded is lost either way —
@@ -12415,6 +13490,7 @@ const paletteListEl = document.getElementById("cmdk-list");
12415
13490
  * the same keyboard already has.
12416
13491
  */
12417
13492
  const IS_MAC = /mac/i.test(navigator.platform || navigator.userAgent || "");
13493
+ const IS_WIN = /win/i.test(navigator.platform || navigator.userAgent || "");
12418
13494
 
12419
13495
  /** The rows the current query left on screen, in the order they are drawn. */
12420
13496
  let paletteRows = [];
@@ -12700,6 +13776,53 @@ function paintStarters() {
12700
13776
  }
12701
13777
  }
12702
13778
 
13779
+ /**
13780
+ * The desktop shell's update hint. The state lives in the main process, which
13781
+ * decides whether a release gets a dialog or only this row; the page draws it
13782
+ * and passes the click back, where the right question is asked.
13783
+ */
13784
+ function paintUpdateHint(state) {
13785
+ const box = document.getElementById("update-hint");
13786
+ if (!box) return;
13787
+ const status = state && state.status;
13788
+ if (status !== "available" && status !== "downloading" && status !== "ready") {
13789
+ box.hidden = true;
13790
+ box.textContent = "";
13791
+ return;
13792
+ }
13793
+ box.hidden = false;
13794
+ box.classList.toggle("important", Boolean(state.important));
13795
+ const button = document.createElement("button");
13796
+ button.type = "button";
13797
+ button.disabled = status === "downloading";
13798
+ if (state.important) button.title = t("update.important");
13799
+ const dot = document.createElement("span");
13800
+ dot.className = "dot";
13801
+ const label = document.createElement("span");
13802
+ label.className = "label";
13803
+ label.textContent =
13804
+ status === "ready"
13805
+ ? t("update.ready")
13806
+ : status === "downloading"
13807
+ ? t("update.downloading", { version: state.version })
13808
+ : t("update.available", { version: state.version });
13809
+ button.append(dot, label);
13810
+ if (status === "downloading") {
13811
+ const pct = document.createElement("span");
13812
+ pct.className = "pct";
13813
+ pct.textContent = (state.percent || 0) + "%";
13814
+ button.append(pct);
13815
+ }
13816
+ button.addEventListener("click", () => window.oharness.updates.act());
13817
+ box.replaceChildren(button);
13818
+ }
13819
+ if (IS_DESKTOP && window.oharness.updates) {
13820
+ window.oharness.updates.onState(paintUpdateHint);
13821
+ // Asked once as well: a release found before this page loaded (or before a
13822
+ // reopened window) was announced to nobody.
13823
+ window.oharness.updates.state().then(paintUpdateHint, () => {});
13824
+ }
13825
+
12703
13826
  void (async () => {
12704
13827
  applyStrings();
12705
13828
  paintStarters();
@@ -12834,6 +13957,7 @@ ${ANALYTICS_STUB}
12834
13957
  people look for one. Signing in is what turns on sync and what lets an
12835
13958
  artifact be published, and until now the only door to it was inside the
12836
13959
  model-provider setup — a place nobody goes to find their account. -->
13960
+ <div id="update-hint" hidden></div>
12837
13961
  <div id="account"></div>
12838
13962
  </aside>
12839
13963
  <div id="scrim"></div>
@@ -12877,7 +14001,13 @@ ${ANALYTICS_STUB}
12877
14001
  <button id="jump" hidden type="button">↓ latest</button>
12878
14002
 
12879
14003
  <form id="composer">
14004
+ <!-- Typed while a turn was running, waiting its turn. Outside the box
14005
+ rather than in it: what is in the box is the message being written,
14006
+ and a row sharing that surface reads as part of it. -->
14007
+ <div id="queued"></div>
12880
14008
  <div class="composer" id="composer-box">
14009
+ <!-- The top edge is a grab handle: drag it to resize the field. -->
14010
+ <div id="grip" role="separator" aria-orientation="horizontal" data-t-title="composer.resize"></div>
12881
14011
  <div id="attachments"></div>
12882
14012
  <!-- A textarea draws its own glyphs and cannot style a range of them, so
12883
14013
  the chip behind a mention is painted on a layer underneath that
@@ -12885,6 +14015,8 @@ ${ANALYTICS_STUB}
12885
14015
  same string twice, and a screen reader should hear it once. -->
12886
14016
  <div class="input-wrap">
12887
14017
  <div id="input-mirror" aria-hidden="true"></div>
14018
+ <div id="caret-probe" aria-hidden="true"></div>
14019
+ <i id="caret" aria-hidden="true"></i>
12888
14020
  <textarea id="input" rows="1" data-t-placeholder="composer.placeholder"></textarea>
12889
14021
  </div>
12890
14022
  <div class="composer-bar">
@@ -13070,11 +14202,11 @@ ${ANALYTICS_STUB}
13070
14202
  </div>
13071
14203
  <div class="dlg-actions">
13072
14204
  <button data-answer="no" class="quiet"><span data-t="ask.no"></span><kbd>esc</kbd></button>
13073
- <button data-answer="once" class="approve"><span data-t="ask.allow"></span><kbd>↵</kbd></button>
14205
+ <button data-answer="once" data-scope="once" class="approve"><span data-t="ask.allow"></span><kbd>↵</kbd></button>
13074
14206
  <button data-answer="session" data-scope="session"><span data-t="ask.session"></span></button>
13075
14207
  <button data-answer="project" data-scope="project"><span data-t="ask.project"></span></button>
13076
14208
  </div>
13077
- <div id="ask-scope" class="ask-scope" hidden></div>
14209
+ <div id="ask-scope" class="ask-scope" data-show="once"><span data-scope="once"></span><span data-scope="session"></span><span data-scope="project"></span></div>
13078
14210
  </dialog>
13079
14211
 
13080
14212
  <script>