@oharness/server 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/page.js CHANGED
@@ -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}
@@ -2119,7 +2305,13 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
2119
2305
  .dlg-actions button.approve kbd{background:#ffffff26}
2120
2306
  /* Only while a "remember" answer is focused, and taking no room otherwise: a
2121
2307
  * 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)}
2308
+ .ask-scope{display:grid;padding:6px 20px 2px;font-size:11.5px;line-height:1.6;color:var(--dim)}
2309
+ .ask-scope>span{
2310
+ grid-area:1/1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
2311
+ opacity:0;pointer-events:none;transition:opacity .12s ease;
2312
+ }
2313
+ .ask-scope[data-show="once"]>[data-scope="once"],.ask-scope[data-show="session"]>[data-scope="session"],
2314
+ .ask-scope[data-show="project"]>[data-scope="project"]{opacity:1;pointer-events:auto}
2123
2315
 
2124
2316
  /* Parameters as a reader wants them: names on the left, values as values —
2125
2317
  * text with its line breaks, lists as chips — instead of JSON with line numbers. */
@@ -2307,6 +2499,9 @@ const isPhone = () => PHONE_QUERY.matches;
2307
2499
  * viewBox and one stroke width with the icons in the markup.
2308
2500
  */
2309
2501
  const ICON_PATHS = {
2502
+ // A padlock: the shackle as a path, the body as a rect.
2503
+ lock: ["M5.33 7.33V5.33a2.67 2.67 0 0 1 5.34 0v2"],
2504
+ lockRects: [[3.33, 7.33, 9.34, 6.67, 1.6]],
2310
2505
  // Same drawing as the Settings item in the nav, so the model picker's
2311
2506
  // "manage" link and the place it leads to are recognisably one thing.
2312
2507
  settings: ["M12.67 4.67h-6", "M9.33 11.33H3.33"],
@@ -2627,6 +2822,7 @@ const cacheEl = document.getElementById("cache");
2627
2822
  const ctxBar = document.querySelector("#ctx i");
2628
2823
  const ctxText = document.getElementById("ctxtext");
2629
2824
  const sessionList = document.getElementById("sessions");
2825
+ const sidebarEl = document.querySelector("body > aside");
2630
2826
  const ask = document.getElementById("ask");
2631
2827
 
2632
2828
  let pendingId = null;
@@ -2756,7 +2952,7 @@ function shortModel(full) {
2756
2952
  return cut < 0 ? full : full.slice(cut + 1);
2757
2953
  }
2758
2954
 
2759
- function makePicker(id, onPick, extra) {
2955
+ function makePicker(id, onPick, extra, opts = {}) {
2760
2956
  const root = document.getElementById(id);
2761
2957
  const button = root.querySelector(".picker-btn");
2762
2958
  const label = root.querySelector(".picker-value");
@@ -2784,18 +2980,25 @@ function makePicker(id, onPick, extra) {
2784
2980
  let cursor = 0;
2785
2981
  /** Groups the user has folded away; survives repainting. */
2786
2982
  const collapsed = new Set();
2983
+ /**
2984
+ * Groups folded while searching. Kept apart from the browsing folds: a
2985
+ * search opens every vendor, because a match behind a closed header looks
2986
+ * like no result — but a header that then refuses to close is a control
2987
+ * that does nothing, and OpenRouter's twenty matches can bury the one vendor
2988
+ * being looked for. Cleared when the query changes, so each search starts
2989
+ * with everything it found on screen.
2990
+ */
2991
+ const searchFolded = new Set();
2992
+ const folded = (group) => (query ? searchFolded.has(group) : collapsed.has(group));
2787
2993
 
2788
2994
  /** 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
- };
2995
+ const visible = () => matches().filter((option) => !option.group || !folded(option.group));
2794
2996
 
2795
2997
  const close = () => {
2796
2998
  menu.hidden = true;
2797
2999
  root.classList.remove("open");
2798
3000
  query = "";
3001
+ searchFolded.clear();
2799
3002
  };
2800
3003
 
2801
3004
  const matches = () => options.filter((option) => matchesQuery(option, query));
@@ -2803,12 +3006,7 @@ function makePicker(id, onPick, extra) {
2803
3006
  const paint = () => {
2804
3007
  const list = matches();
2805
3008
 
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
- );
3009
+ const shown = list.filter((option) => !option.group || !folded(option.group));
2812
3010
  if (cursor >= shown.length) cursor = Math.max(0, shown.length - 1);
2813
3011
 
2814
3012
  // Only the body is rebuilt: replacing the menu itself would take the
@@ -2828,7 +3026,7 @@ function makePicker(id, onPick, extra) {
2828
3026
  if (option.group && option.group !== lastGroup) {
2829
3027
  lastGroup = option.group;
2830
3028
  const group = option.group;
2831
- const header = el("button", "menu-group" + (collapsed.has(group) && !searching ? "" : " open"));
3029
+ const header = el("button", "menu-group" + (folded(group) ? "" : " open"));
2832
3030
  header.type = "button";
2833
3031
  header.appendChild(el("span", "chev"));
2834
3032
  // The vendor's mark leads its header, so a folded list reads as a
@@ -2844,15 +3042,16 @@ function makePicker(id, onPick, extra) {
2844
3042
  header.appendChild(el("span", "g-count", String(counts.get(group) || 0)));
2845
3043
  header.addEventListener("click", (e) => {
2846
3044
  e.stopPropagation();
2847
- if (collapsed.has(group)) collapsed.delete(group);
2848
- else collapsed.add(group);
3045
+ const set = query ? searchFolded : collapsed;
3046
+ if (set.has(group)) set.delete(group);
3047
+ else set.add(group);
2849
3048
  cursor = 0;
2850
3049
  paint();
2851
3050
  });
2852
3051
  body.appendChild(header);
2853
3052
  }
2854
3053
 
2855
- if (option.group && collapsed.has(option.group) && !searching) continue;
3054
+ if (option.group && folded(option.group)) continue;
2856
3055
  const i = visibleIndex++;
2857
3056
  const item = el(
2858
3057
  "button",
@@ -2865,6 +3064,10 @@ function makePicker(id, onPick, extra) {
2865
3064
  const mark = option.mark && option.mark();
2866
3065
  if (mark) item.appendChild(mark);
2867
3066
  item.appendChild(el("span", "menu-label", option.label));
3067
+ // Beside the name rather than out at the right margin where sub sits:
3068
+ // this qualifies the model it is attached to, and a badge floated away
3069
+ // from its name is a badge the eye has to pair up again on every row.
3070
+ if (option.tag) item.appendChild(el("span", "m-tag", option.tag));
2868
3071
  if (option.sub) item.appendChild(el("span", "sub", option.sub));
2869
3072
  item.addEventListener("click", () => {
2870
3073
  close();
@@ -2888,10 +3091,14 @@ function makePicker(id, onPick, extra) {
2888
3091
 
2889
3092
  // Everything folded except the vendor holding the current model: opening
2890
3093
  // onto a dozen closed headers hides the one answer the user already has.
3094
+ // A picker may name groups that always open as well — the model picker's
3095
+ // own plan, which is where someone looking for a model should land first.
2891
3096
  collapsed.clear();
2892
3097
  const current = options.find((option) => option.value === value);
3098
+ const alwaysOpen = new Set(opts.openGroups || []);
2893
3099
  for (const option of options) {
2894
- if (option.group && option.group !== (current && current.group)) collapsed.add(option.group);
3100
+ if (!option.group || alwaysOpen.has(option.group)) continue;
3101
+ if (option.group !== (current && current.group)) collapsed.add(option.group);
2895
3102
  }
2896
3103
  cursor = Math.max(0, visible().findIndex((option) => option.value === value));
2897
3104
  // Searching costs a row of chrome and is pointless below a handful of
@@ -2912,6 +3119,7 @@ function makePicker(id, onPick, extra) {
2912
3119
  };
2913
3120
 
2914
3121
  search.addEventListener("input", () => {
3122
+ if (search.value !== query) searchFolded.clear();
2915
3123
  query = search.value;
2916
3124
  cursor = 0;
2917
3125
  paint();
@@ -3035,6 +3243,7 @@ const modelPicker = makePicker(
3035
3243
  });
3036
3244
  return link;
3037
3245
  },
3246
+ { openGroups: ["official"] },
3038
3247
  );
3039
3248
  const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
3040
3249
 
@@ -3437,7 +3646,7 @@ function workGroup() {
3437
3646
  if (!group) {
3438
3647
  group = el("details", "work");
3439
3648
  const summary = el("summary");
3440
- summary.appendChild(el("span", "work-label", "Working"));
3649
+ summary.appendChild(el("span", "work-label", t("work.working")));
3441
3650
  group.appendChild(summary);
3442
3651
  group.appendChild(el("div", "work-body"));
3443
3652
  parent.appendChild(group);
@@ -3445,10 +3654,127 @@ function workGroup() {
3445
3654
  return group;
3446
3655
  }
3447
3656
 
3657
+ /**
3658
+ * A run of tool calls, said in a line: what was done, not how many steps it
3659
+ * took.
3660
+ *
3661
+ * "3 steps" was true and told nobody anything; the reason to look at a folded
3662
+ * group is to know whether it is the part worth opening. So the line is built
3663
+ * from the calls themselves. A call that says what it is for — a command's
3664
+ * description, a subagent's task — is quoted; the rest are named by what they
3665
+ * do, and neighbours of one kind are counted rather than listed ("Read 3
3666
+ * files"). While a call is running its phrase is in the present ("Running a
3667
+ * command"), and a failure leads, in red, because that is the one thing
3668
+ * nobody should have to open the group to find. Two phrases are shown and the
3669
+ * rest counted, which is how Claude Code's desktop app reads.
3670
+ */
3448
3671
  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";
3672
+ const cards = [...group.querySelectorAll(":scope > .work-body > details.tool")];
3673
+ const phrases = [];
3674
+ let failed = false;
3675
+ let failures = 0;
3676
+ for (const card of cards) {
3677
+ const info = card.toolInfo || {};
3678
+ const state = card.classList.contains("failed") ? "failed" : card.classList.contains("done") ? "done" : "running";
3679
+ const input = info.input && typeof info.input === "object" ? info.input : {};
3680
+ const described = typeof input.description === "string" ? input.description.trim() : "";
3681
+ if (state === "failed") {
3682
+ failed = true;
3683
+ // First, not in sequence: counted into a "+2" it would be the one
3684
+ // phrase the red line is about and the one it does not show.
3685
+ phrases.splice(failures++, 0, { text: failedPhrase(info.name, input, described) });
3686
+ continue;
3687
+ }
3688
+ if (described) {
3689
+ phrases.push({ text: described });
3690
+ continue;
3691
+ }
3692
+ const kind = workKind(info.name);
3693
+ const last = phrases.at(-1);
3694
+ if (last && last.kind === kind && last.state === state) {
3695
+ last.count += 1;
3696
+ continue;
3697
+ }
3698
+ phrases.push({ kind, state, count: 1, name: info.name, input });
3699
+ }
3700
+
3701
+ const texts = phrases.map((phrase) => phrase.text || kindPhrase(phrase));
3702
+ const shown = texts.slice(0, 2).map((text, i) => (i ? lowerFirst(text) : text));
3703
+ let label = shown.join(t("work.sep"));
3704
+ if (texts.length > 2) label += " +" + (texts.length - 2);
3705
+ group.querySelector(".work-label").textContent = label || t("work.working");
3706
+ group.classList.toggle("failed", failed);
3707
+ }
3708
+
3709
+ /** Which verb a tool gets when it did not say what it was for. */
3710
+ function workKind(name) {
3711
+ if (name === "bash") return "command";
3712
+ if (name === "read" || name === "artifact_read" || name === "mcp_read_resource") return "read";
3713
+ if (name === "write" || name === "artifact_write") return "write";
3714
+ if (name === "edit") return "edit";
3715
+ if (name === "list" || name === "mcp_list_resources") return "list";
3716
+ if (name === "glob" || name === "grep") return "search";
3717
+ if (name === "recall" || name === "memory") return "recall";
3718
+ if (name === "todo_write") return "plan";
3719
+ if (name === "skill") return "skill";
3720
+ if (name === "ask_user") return "ask";
3721
+ return "other";
3722
+ }
3723
+
3724
+ /** The last segment of a path, which is what a person recognises. */
3725
+ function baseName(value) {
3726
+ if (typeof value !== "string" || !value) return "";
3727
+ return value.replace(/\/+$/, "").split("/").pop() || value;
3728
+ }
3729
+
3730
+ function kindPhrase(phrase) {
3731
+ const now = phrase.state === "running";
3732
+ const many = phrase.count > 1;
3733
+ const file = baseName(phrase.input.path);
3734
+ switch (phrase.kind) {
3735
+ case "command":
3736
+ return now ? t("work.commandNow") : many ? t("work.commandN", { n: phrase.count }) : t("work.command");
3737
+ case "read":
3738
+ case "write":
3739
+ case "edit":
3740
+ if (now) return t("work." + phrase.kind + "Now", { file: file || "…" });
3741
+ if (many) return t("work." + phrase.kind + "N", { n: phrase.count });
3742
+ return t("work." + phrase.kind, { file: file || "…" });
3743
+ case "list":
3744
+ // No path is the working directory, and "Listed ." reads as a typo.
3745
+ if (!file || file === ".") return t(now ? "work.listHereNow" : "work.listHere");
3746
+ return t(now ? "work.listNow" : "work.list", { dir: file });
3747
+ case "search":
3748
+ return now ? t("work.searchNow") : many ? t("work.searchN", { n: phrase.count }) : t("work.search");
3749
+ case "recall":
3750
+ case "plan":
3751
+ case "ask":
3752
+ return t("work." + phrase.kind + (now ? "Now" : ""));
3753
+ case "skill":
3754
+ return t(now ? "work.skillNow" : "work.skill", { name: String(phrase.input.name || "") });
3755
+ default:
3756
+ return t(now ? "work.otherNow" : "work.other", { tool: toolTitle(phrase.name, phrase.input) });
3757
+ }
3758
+ }
3759
+
3760
+ function failedPhrase(name, input, described) {
3761
+ if (name === "bash") {
3762
+ const command = typeof input.command === "string" ? input.command.trim().split("\n")[0] : "";
3763
+ const what = described || (command.length > 32 ? command.slice(0, 32) + "…" : command);
3764
+ return t("work.failedRun", { what: what || t("work.aCommand") });
3765
+ }
3766
+ return t("work.failed", { tool: described || toolTitle(name, input) });
3767
+ }
3768
+
3769
+ /**
3770
+ * The second phrase continues a sentence, so it starts lower-case — unless it
3771
+ * opens with a name or an acronym, which keeps its capital.
3772
+ */
3773
+ function lowerFirst(text) {
3774
+ if (text.length > 1 && text[1] === text[1].toLowerCase() && /^[A-Z]/.test(text)) {
3775
+ return text[0].toLowerCase() + text.slice(1);
3776
+ }
3777
+ return text;
3452
3778
  }
3453
3779
 
3454
3780
  // --- tool presentation -----------------------------------------------------
@@ -3569,15 +3895,61 @@ function catalogSummary(text) {
3569
3895
  return "";
3570
3896
  }
3571
3897
 
3572
- function toolCard(id, title, name, input) {
3898
+ /**
3899
+ * A card for a call the model is still writing: the name is known, the
3900
+ * arguments are not. Without it a long file write was a reply that said "now
3901
+ * writing the file" and then showed nothing until the whole file had streamed.
3902
+ */
3903
+ function pendingToolCard(id, name) {
3904
+ if (P.tools.has(id)) return;
3573
3905
  const card = el("details", "tool");
3574
3906
  const summary = el("summary");
3575
3907
  summary.appendChild(el("span", "dot"));
3576
- summary.appendChild(el("span", "name", toolTitle(name, input, title)));
3908
+ summary.appendChild(el("span", "name", toolTitle(name)));
3577
3909
  summary.appendChild(el("span", "ms", ""));
3578
3910
  card.appendChild(summary);
3579
-
3580
3911
  const body = el("div", "tool-body");
3912
+ card.appendChild(body);
3913
+
3914
+ card.toolInfo = { name };
3915
+ const group = workGroup();
3916
+ group.querySelector(".work-body").appendChild(card);
3917
+ bumpWorkLabel(group);
3918
+ P.tools.set(id, { card, body, name, input: undefined, group, pending: true });
3919
+ P.streaming = null;
3920
+ follow();
3921
+ }
3922
+
3923
+ /**
3924
+ * Drop the cards for calls that were announced and never ran — an unknown tool
3925
+ * or bad input is answered without a tool_start, and a retried request gets
3926
+ * fresh ids. Left alone they would pulse forever.
3927
+ */
3928
+ function dropPendingTools() {
3929
+ for (const [id, entry] of P.tools) {
3930
+ if (!entry.pending) continue;
3931
+ entry.card.remove();
3932
+ P.tools.delete(id);
3933
+ const count = entry.group.querySelector(".work-body").children.length;
3934
+ if (count) bumpWorkLabel(entry.group);
3935
+ else entry.group.remove();
3936
+ }
3937
+ }
3938
+
3939
+ function toolCard(id, title, name, input) {
3940
+ const announced = P.tools.get(id);
3941
+ const card = announced ? announced.card : el("details", "tool");
3942
+ if (announced) {
3943
+ card.querySelector(".name").textContent = toolTitle(name, input, title);
3944
+ } else {
3945
+ const summary = el("summary");
3946
+ summary.appendChild(el("span", "dot"));
3947
+ summary.appendChild(el("span", "name", toolTitle(name, input, title)));
3948
+ summary.appendChild(el("span", "ms", ""));
3949
+ card.appendChild(summary);
3950
+ }
3951
+
3952
+ const body = announced ? announced.body : el("div", "tool-body");
3581
3953
  // An app call is its arguments; the app, tool and version are already the
3582
3954
  // title or plumbing. A call with nothing to show gets no "input" label over
3583
3955
  // an empty {} — which was most of what a catalog lookup used to draw. A
@@ -3594,6 +3966,15 @@ function toolCard(id, title, name, input) {
3594
3966
  body.appendChild(pre);
3595
3967
  }
3596
3968
  }
3969
+ card.toolInfo = { name, input };
3970
+ if (announced) {
3971
+ P.tools.set(id, { card, body, name, input, group: announced.group });
3972
+ // The arguments have arrived, and with them the description or the path
3973
+ // the line was waiting for.
3974
+ bumpWorkLabel(announced.group);
3975
+ follow();
3976
+ return;
3977
+ }
3597
3978
  card.appendChild(body);
3598
3979
 
3599
3980
  const group = workGroup();
@@ -3609,6 +3990,8 @@ function finishTool(id, result, ms, isError) {
3609
3990
  if (!entry) return;
3610
3991
  entry.card.classList.add(isError ? "failed" : "done");
3611
3992
  entry.card.querySelector(".ms").textContent = ms + "ms";
3993
+ // Running becomes ran, or the group turns red.
3994
+ if (entry.group) bumpWorkLabel(entry.group);
3612
3995
 
3613
3996
  const text = (result || []).map((part) => (part.type === "text" ? part.text : "")).join("");
3614
3997
  entry.body.appendChild(el("div", "label", isError ? "error" : "result"));
@@ -3906,6 +4289,24 @@ function toggleProject(cwd, open) {
3906
4289
  paintSessions();
3907
4290
  }
3908
4291
 
4292
+ /** The key the routine group's open state is kept under, beside the projects'. */
4293
+ const ROUTINE_GROUP = "\u0000routines";
4294
+
4295
+ /**
4296
+ * The header over routine runs. A fold like a project's, with the clock the
4297
+ * Routines page uses, and none of a project's actions: there is no new session
4298
+ * to start in it, and deleting runs is done row by row.
4299
+ */
4300
+ function routineHead(count, open) {
4301
+ const head = el("button", "fold" + (open ? " open" : ""));
4302
+ head.type = "button";
4303
+ head.appendChild(navIcon("nav-routines"));
4304
+ head.appendChild(el("span", "fold-name", t("session.routines")));
4305
+ head.appendChild(el("span", "count", String(count)));
4306
+ head.addEventListener("click", () => toggleProject(ROUTINE_GROUP, open));
4307
+ return head;
4308
+ }
4309
+
3909
4310
  /** A project header: a folder, a name, and how many sessions it holds. */
3910
4311
  function projectHead(cwd, name, count, open) {
3911
4312
  const head = el("button", "fold" + (open ? " open" : ""));
@@ -3949,7 +4350,17 @@ function projectHead(cwd, name, count, open) {
3949
4350
  more.addEventListener("click", (e) => {
3950
4351
  e.stopPropagation();
3951
4352
  if (menuRow === head) return closeRowMenu();
4353
+ openMenu(more);
4354
+ });
4355
+ // The same menu under the pointer: a right-click on a project is asking for
4356
+ // it, and the browser's own (Back, Reload, View Source) is about the page.
4357
+ head.addEventListener("contextmenu", (e) => {
4358
+ e.preventDefault();
4359
+ e.stopPropagation();
4360
+ openMenu(pointAnchor(e));
4361
+ });
3952
4362
 
4363
+ function openMenu(anchor) {
3953
4364
  // Armed once before it fires, like the session row it borrows from: this
3954
4365
  // is a modal's worth of consequence reached from a list row, and the
3955
4366
  // consequence is spelled out where a dialog would put it.
@@ -3958,7 +4369,7 @@ function projectHead(cwd, name, count, open) {
3958
4369
  del.classList.add("armed");
3959
4370
  del.querySelector(".menu-label").textContent = t("projects.deleteConfirm");
3960
4371
  rowMenu.replaceChildren(el("div", "menu-note", t("projects.deleteNote")), del);
3961
- placeRowMenu(more);
4372
+ placeRowMenu(anchor);
3962
4373
  return;
3963
4374
  }
3964
4375
  closeRowMenu();
@@ -3971,6 +4382,20 @@ function projectHead(cwd, name, count, open) {
3971
4382
  closeRowMenu();
3972
4383
  void chooseProject(cwd);
3973
4384
  } }),
4385
+ menuItem(t("projects.rename"), { key: "n", run: () => {
4386
+ closeRowMenu();
4387
+ void renameProjectUi(cwd, name);
4388
+ } }),
4389
+ menuItem(t(IS_MAC ? "projects.revealMac" : IS_WIN ? "projects.revealWin" : "projects.reveal"), { key: "f", run: () => {
4390
+ closeRowMenu();
4391
+ void api("/reveal", { path: cwd }).then((body) => {
4392
+ if (body && body.error) addNotice(body.error, "err");
4393
+ });
4394
+ } }),
4395
+ menuItem(t("projects.export"), { key: "e", run: () => {
4396
+ closeRowMenu();
4397
+ exportProjectUi(cwd);
4398
+ } }),
3974
4399
  menuItem(t(pinned ? "projects.unpin" : "projects.pin"), { key: "p", run: () => {
3975
4400
  closeRowMenu();
3976
4401
  void togglePin("project", cwd, !pinned);
@@ -3992,8 +4417,8 @@ function projectHead(cwd, name, count, open) {
3992
4417
  } }));
3993
4418
  }
3994
4419
  items.push(menuSep(), del);
3995
- openRowMenu(more, head, items);
3996
- });
4420
+ openRowMenu(anchor, head, items);
4421
+ }
3997
4422
 
3998
4423
  // The header is a button, so the menu control cannot live inside it.
3999
4424
  const wrap = el("div", "fold-row");
@@ -4147,8 +4572,14 @@ function paintSessions() {
4147
4572
  const active = sessionRows.find((s) => s.current);
4148
4573
  const here = active ? active.cwd : (projects.find((p) => p.current) || {}).path;
4149
4574
 
4575
+ // A routine's runs are kept together rather than mixed into the project's
4576
+ // own work: they arrive on their own, often overnight, and the question
4577
+ // someone opens them with is "what did my routines do", not "what was I
4578
+ // doing in this project". Claude Code's desktop app draws the same line.
4579
+ const scheduled = sessionRows.filter((s) => s.routine);
4150
4580
  const byProject = new Map();
4151
4581
  for (const s of sessionRows) {
4582
+ if (s.routine) continue;
4152
4583
  const key = s.cwd || "";
4153
4584
  if (!byProject.has(key)) byProject.set(key, []);
4154
4585
  byProject.get(key).push(s);
@@ -4172,7 +4603,19 @@ function paintSessions() {
4172
4603
  // Everything here belongs to the project already named at the top of the
4173
4604
  // sidebar: a header repeating it is noise, and the rows do not need
4174
4605
  // indenting under a heading that is not there.
4175
- const named = !(order.length === 1 && order[0] === here);
4606
+ const named = !(order.length === 1 && order[0] === here) || scheduled.length > 0;
4607
+
4608
+ if (scheduled.length) {
4609
+ const open = ROUTINE_GROUP in projectOpen ? projectOpen[ROUTINE_GROUP] === true : true;
4610
+ sessionList.appendChild(routineHead(scheduled.length, open));
4611
+ if (open) {
4612
+ const all = showingAll.has(ROUTINE_GROUP) || scheduled.length <= PREVIEW + 1;
4613
+ for (const s of all ? scheduled : scheduled.slice(0, PREVIEW)) {
4614
+ sessionList.appendChild(sessionRow(s, true));
4615
+ }
4616
+ if (!all) sessionList.appendChild(showMore(ROUTINE_GROUP, true));
4617
+ }
4618
+ }
4176
4619
 
4177
4620
  for (const cwd of order) {
4178
4621
  const list = arrange(byProject.get(cwd), (s) => s.id, sessionOrder[cwd], (s) => s.pinned);
@@ -4387,6 +4830,15 @@ function menuSep() {
4387
4830
  return el("div", "menu-sep");
4388
4831
  }
4389
4832
 
4833
+ /**
4834
+ * Something placeRowMenu can hang a menu from when there is no button: the
4835
+ * point a right-click landed on, as a rectangle with no size.
4836
+ */
4837
+ function pointAnchor(e) {
4838
+ const rect = { left: e.clientX, right: e.clientX, top: e.clientY, bottom: e.clientY, width: 0, height: 0 };
4839
+ return { getBoundingClientRect: () => rect };
4840
+ }
4841
+
4390
4842
  function openRowMenu(button, row, items) {
4391
4843
  closeRowMenu();
4392
4844
  rowMenu.replaceChildren(...items);
@@ -4415,6 +4867,13 @@ document.addEventListener("keydown", (e) => {
4415
4867
  });
4416
4868
  window.addEventListener("resize", closeRowMenu);
4417
4869
  sessionList.addEventListener("scroll", closeRowMenu);
4870
+ // Nowhere in the sidebar is the browser's menu the one meant: a row answers
4871
+ // with its own above, and the space between rows answers with nothing rather
4872
+ // than with Back, Reload and View Source. Text fields keep theirs — paste.
4873
+ sidebarEl.addEventListener("contextmenu", (e) => {
4874
+ if (e.target.closest("input, textarea, [contenteditable]")) return;
4875
+ e.preventDefault();
4876
+ });
4418
4877
 
4419
4878
  function sessionRow(s, nested) {
4420
4879
  const item = el("div", "session" + (s.current ? " active" : "") + (nested ? " nested" : ""));
@@ -4469,7 +4928,17 @@ function sessionRow(s, nested) {
4469
4928
  more.addEventListener("click", (e) => {
4470
4929
  e.stopPropagation();
4471
4930
  if (menuRow === item) return closeRowMenu();
4931
+ openMenu(more);
4932
+ });
4933
+ // Right-click opens the row's own menu where the pointer is, instead of the
4934
+ // browser's, which has nothing to say about a session.
4935
+ item.addEventListener("contextmenu", (e) => {
4936
+ e.preventDefault();
4937
+ e.stopPropagation();
4938
+ openMenu(pointAnchor(e));
4939
+ });
4472
4940
 
4941
+ function openMenu(anchor) {
4473
4942
  // A transcript cannot be recovered, so the item asks once rather than
4474
4943
  // deleting on the click that opened the menu — but a modal for something
4475
4944
  // reached from a list row is heavier than what it protects against. The
@@ -4479,14 +4948,14 @@ function sessionRow(s, nested) {
4479
4948
  del.classList.add("armed");
4480
4949
  del.querySelector(".menu-label").textContent = t("session.deleteConfirm");
4481
4950
  rowMenu.replaceChildren(el("div", "menu-note", t("session.deleteNote")), del);
4482
- placeRowMenu(more);
4951
+ placeRowMenu(anchor);
4483
4952
  return;
4484
4953
  }
4485
4954
  closeRowMenu();
4486
4955
  void deleteSession(s.id);
4487
4956
  } });
4488
4957
 
4489
- openRowMenu(more, item, [
4958
+ openRowMenu(anchor, item, [
4490
4959
  menuItem(t(s.pinned ? "session.unpin" : "session.pin"), { key: "p", run: () => {
4491
4960
  closeRowMenu();
4492
4961
  void togglePin("session", s.id, !s.pinned);
@@ -4504,7 +4973,7 @@ function sessionRow(s, nested) {
4504
4973
  menuSep(),
4505
4974
  del,
4506
4975
  ]);
4507
- });
4976
+ }
4508
4977
  item.appendChild(more);
4509
4978
 
4510
4979
  item.addEventListener("click", () => {
@@ -4648,12 +5117,30 @@ async function loadModels() {
4648
5117
  const markFor = (id) =>
4649
5118
  markers.get(id) || ((extra) => providerMark(id, id, extra));
4650
5119
 
5120
+ // No context window here. It is a number you consult when choosing a model
5121
+ // once, not every time you pick one off a list you already know, and printed
5122
+ // down the right of four hundred rows it competes with the only thing the
5123
+ // row is for — the name. The panel and the provider dialog still carry it,
5124
+ // where a model is being chosen rather than switched to.
4651
5125
  const listed = models.map((m) => ({
4652
5126
  value: m.id,
4653
- label: m.id,
5127
+ // Without the provider: the group header above the row already names it,
5128
+ // and "official/" repeated down every row pushes the part that differs to
5129
+ // the right. An aggregator's own namespace stays — "~anthropic/…" under
5130
+ // OpenRouter is what tells its rows apart. The whole id is still what
5131
+ // search matches, so typing it as /model takes it works here too.
5132
+ label: m.id.indexOf(m.provider + "/") === 0 ? m.id.slice(m.provider.length + 1) : m.id,
5133
+ keywords: m.id,
4654
5134
  group: m.provider,
4655
5135
  mark: markFor(m.provider),
4656
- sub: m.contextWindow ? Math.round(m.contextWindow / 1000) + "k" : "",
5136
+ // The id is left whole even where it already ends in ":free". That suffix
5137
+ // is OpenRouter's routing variant and part of the string somebody types
5138
+ // into /model, and trimming it to save the repetition would render
5139
+ // minimax-m2.7 and minimax-m2.7:free as the same row — two models that
5140
+ // differ in what they cost, told apart by a badge alone. A vendor whose
5141
+ // free models are not named for it gets the badge and nothing else, which
5142
+ // is the case this exists for.
5143
+ ...(m.free ? { tag: t("models.free") } : {}),
4657
5144
  }));
4658
5145
 
4659
5146
  // A vendor with nothing to list is still an answer. Local servers and the
@@ -4675,10 +5162,14 @@ async function loadModels() {
4675
5162
  // nothing discovered yet landed under four hundred rows of the vendors that
4676
5163
  // had — which put the recommended plan, whose whole roster arrives only
4677
5164
  // after signing in, at the very bottom of the menu meant to sell it.
5165
+ // Within a vendor, the server's order: newest first by family, variants
5166
+ // after their model (model-order.ts). A string sort here put claude-opus-4-5
5167
+ // above claude-opus-5 and scattered :batch rows through the list.
5168
+ const rank = new Map(models.map((m, i) => [m.id, i]));
4678
5169
  modelOptions = listed.concat(stubs).sort(
4679
5170
  (a, b) =>
4680
5171
  (order.get(a.group) ?? 9999) - (order.get(b.group) ?? 9999) ||
4681
- a.value.localeCompare(b.value),
5172
+ (rank.get(a.value) ?? 0) - (rank.get(b.value) ?? 0),
4682
5173
  );
4683
5174
 
4684
5175
  const current = models.find((m) => m.current);
@@ -4938,6 +5429,12 @@ events.onmessage = (message) => {
4938
5429
  }
4939
5430
 
4940
5431
  case "assistant_message": P.streaming = null; break;
5432
+ case "turn_start": dropPendingTools(); break;
5433
+ case "tool_pending":
5434
+ sealThinking();
5435
+ pendingToolCard(event.toolCallId, event.toolName);
5436
+ startWorking(toolTitle(event.toolName));
5437
+ break;
4941
5438
  case "tool_start":
4942
5439
  sealThinking();
4943
5440
  toolCard(event.toolCallId, event.title, event.toolName, event.input);
@@ -5030,6 +5527,12 @@ events.onmessage = (message) => {
5030
5527
  addNotice(t("memory.extracted", { count: event.notes.length, list: event.notes.map((note) => note.summary).join("; ") }));
5031
5528
  break;
5032
5529
  case "notice": addNotice(event.message); break;
5530
+ // A routine ran: its session is new, and the Routines page, if open, has
5531
+ // a result to show.
5532
+ case "sessions":
5533
+ void loadSessions();
5534
+ if (shownPanel === "nav-routines") void showRoutines();
5535
+ break;
5033
5536
  case "error":
5034
5537
  P.streaming = null;
5035
5538
  // A 402 is not an error to print, it is a screen to open. What the
@@ -5048,6 +5551,7 @@ events.onmessage = (message) => {
5048
5551
  case "done": {
5049
5552
  sealThinking();
5050
5553
  stopWorking();
5554
+ dropPendingTools();
5051
5555
  // The fork point comes from the server: one reply can span several
5052
5556
  // stored messages, and a client-side count would drift the moment tool
5053
5557
  // results land in a message of their own.
@@ -5348,8 +5852,16 @@ function showAsk(event) {
5348
5852
  askState = { request, view, expanded: false, raw: false };
5349
5853
 
5350
5854
  paintAskHead(view, request);
5351
- askScope.textContent = "";
5352
- askScope.hidden = true;
5855
+ // Every description is laid out up front, one line each and stacked in one
5856
+ // cell, so hovering swaps the text without resizing the dialog. The rule key
5857
+ // closes each line: a long path is what gets cut, and the tooltip has it all.
5858
+ const ruleKey = askRuleKey(request);
5859
+ for (const line of askScope.children) {
5860
+ const scope = line.dataset.scope;
5861
+ line.textContent = t(scope === "once" ? "ask.scopeOnce" : scope === "session" ? "ask.scopeSession" : "ask.scopeProject", { key: ruleKey });
5862
+ line.title = line.textContent;
5863
+ }
5864
+ askScope.dataset.show = "once";
5353
5865
  renderAskBody();
5354
5866
  // The palette is a convenience; this is a turn waiting on an answer. Two
5355
5867
  // stacked modals would leave the deeper one unreachable behind the newer.
@@ -5412,15 +5924,10 @@ for (const button of ask.querySelectorAll("[data-answer]")) {
5412
5924
  const scope = button.dataset.scope;
5413
5925
  if (!scope) continue;
5414
5926
  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;
5927
+ askScope.dataset.show = scope;
5420
5928
  };
5421
5929
  const hide = () => {
5422
- askScope.textContent = "";
5423
- askScope.hidden = true;
5930
+ askScope.dataset.show = "once";
5424
5931
  };
5425
5932
  button.addEventListener("mouseenter", describe);
5426
5933
  button.addEventListener("focus", describe);
@@ -6975,6 +7482,17 @@ const COMMANDS = [
6975
7482
  return void api("/model", { model: arg }).then(pushNotice);
6976
7483
  },
6977
7484
  },
7485
+ {
7486
+ name: "login",
7487
+ arg: "",
7488
+ hint: () => t("cmd.login"),
7489
+ // The same dialog the sidebar's sign-in row opens, so every way in — the
7490
+ // website and the emailed code — is on screen.
7491
+ run: async () => {
7492
+ const account = await getJson("/account");
7493
+ openConnect((account && account.provider) || "official");
7494
+ },
7495
+ },
6978
7496
  {
6979
7497
  name: "mode",
6980
7498
  arg: "mode",
@@ -7193,7 +7711,8 @@ document.getElementById("composer").addEventListener("submit", (e) => {
7193
7711
  if (runCommand(text)) {
7194
7712
  input.value = "";
7195
7713
  input.style.height = "auto";
7196
- closeAttachMenu();
7714
+ paintMirror();
7715
+ closeAttachMenu();
7197
7716
  return;
7198
7717
  }
7199
7718
  // A question waiting in this tab takes what is typed as its answer. People
@@ -8370,6 +8889,11 @@ async function showModels() {
8370
8889
  if (model.contextWindow) {
8371
8890
  chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
8372
8891
  }
8892
+ // Only ever on a model whose vendor quoted zero for both halves.
8893
+ // The server's isFree is the whole of the decision, so a chip without
8894
+ // this is a model nobody has said is free, not one that is known to
8895
+ // cost something.
8896
+ if (model.free) chip.appendChild(el("span", "free", t("models.free")));
8373
8897
  chip.addEventListener("click", () => {
8374
8898
  track("model_switched", { model: model.id, via: "panel" });
8375
8899
  void api("/model", { session: activeId, id: model.id }).then(async (state) => {
@@ -8551,9 +9075,33 @@ function openConnect(providerId, thenModel) {
8551
9075
  field.autocomplete = "off";
8552
9076
  field.spellcheck = false;
8553
9077
 
8554
- const hint = el("div", "cn-note");
9078
+ const hint = el("div", "cn-note cn-tip");
8555
9079
  hint.hidden = true;
9080
+ hint.setAttribute("role", "tooltip");
8556
9081
  const why = document.getElementById("cn-why");
9082
+ // In the dialog's column rather than its body, so it floats over the body
9083
+ // instead of taking a place in it. Left from the last opening otherwise.
9084
+ const main = why.closest(".cn-main");
9085
+ for (const old of main.querySelectorAll(":scope > .cn-tip")) old.remove();
9086
+ main.appendChild(hint);
9087
+
9088
+ // A third-party key is the one people hesitate over pasting into a new app,
9089
+ // so the dialog says, in plain sight, where it goes and what does not happen
9090
+ // to it. Not for OHarness's own key, which is ours to issue. And not
9091
+ // "secure": it is a plain file only this account can read, and claiming
9092
+ // more would promise something the file does not do.
9093
+ const thirdParty = provider.id !== "official";
9094
+ // One line, the fact in bold. The path and the file mode are behind the
9095
+ // mark with the rest of the detail, for whoever wants to check.
9096
+ // Who the key goes to, by name: "sent only to Together AI" is a claim a
9097
+ // person can check against the address the request goes to, where "stays
9098
+ // on this computer" alone is not quite true — the provider has to see it.
9099
+ const keptLocal = el("div", "cn-note cn-local");
9100
+ keptLocal.appendChild(svgIcon("lock"));
9101
+ const words = el("span");
9102
+ words.appendChild(el("strong", null, t("models.keyLocalLead")));
9103
+ words.appendChild(document.createTextNode(t("models.keyLocalRest", { provider: provider.label || provider.id })));
9104
+ keptLocal.appendChild(words);
8557
9105
 
8558
9106
  // Hovering shows it, clicking keeps it. Peeking is the common case and a
8559
9107
  // click to see two lines and another to put them away is two clicks more
@@ -8659,6 +9207,7 @@ function openConnect(providerId, thenModel) {
8659
9207
  docs.hidden = true;
8660
9208
  }
8661
9209
  why.hidden = mode !== "key";
9210
+ keptLocal.hidden = !(mode === "key" && thirdParty);
8662
9211
  // Hidden rather than left out, so switching modes does not rebuild a
8663
9212
  // button whose click may already have a sign-in running behind it.
8664
9213
  site.hidden = mode !== "account";
@@ -8827,7 +9376,7 @@ function openConnect(providerId, thenModel) {
8827
9376
  site,
8828
9377
  orCode,
8829
9378
  field,
8830
- hint,
9379
+ keptLocal,
8831
9380
  );
8832
9381
  // Re-attached on every open: the actions row is markup, so a link left from
8833
9382
  // the last vendor would otherwise point at their key page. The link is built
@@ -9836,6 +10385,7 @@ function openProviderPicker(rest, byProvider) {
9836
10385
  if (model.contextWindow) {
9837
10386
  chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
9838
10387
  }
10388
+ if (model.free) chip.appendChild(el("span", "free", t("models.free")));
9839
10389
  chip.addEventListener("click", (e) => {
9840
10390
  e.preventDefault();
9841
10391
  e.stopPropagation();
@@ -9903,6 +10453,71 @@ document.getElementById("nav-models").addEventListener("click", () => {
9903
10453
 
9904
10454
  // --- routines and settings -------------------------------------------------
9905
10455
 
10456
+ /**
10457
+ * Start a routine by describing it.
10458
+ *
10459
+ * A fresh session with the sentence begun, rather than a form: "every weekday
10460
+ * morning, summarise yesterday's commits" is how people say it, and the agent
10461
+ * turns that into a cron and a prompt that stands on its own. A form asked for
10462
+ * five cron fields to express the same thing.
10463
+ */
10464
+ async function newRoutine() {
10465
+ await switchTo({ action: "new" });
10466
+ input.value = t("routines.starter");
10467
+ paintMirror();
10468
+ input.focus();
10469
+ input.setSelectionRange(input.value.length, input.value.length);
10470
+ }
10471
+
10472
+ /** One run as a line: when, how it went, and what it cost. */
10473
+ function runSummary(run) {
10474
+ const when = new Date(run.startedAt).toLocaleString();
10475
+ if (run.skipped) return t("routines.skipped", { when });
10476
+ const parts = [t(run.ok ? "routines.lastOk" : "routines.lastFailed", { when })];
10477
+ if (run.catchUp) parts.push(t("routines.catchUp"));
10478
+ parts.push(t("routines.turns", { n: run.turns }));
10479
+ if (run.costUsd) parts.push("$" + run.costUsd.toFixed(4));
10480
+ return parts.join(" · ");
10481
+ }
10482
+
10483
+ /**
10484
+ * A routine's past runs, fetched when opened.
10485
+ *
10486
+ * Skipped times are listed with the runs, with why: a routine that "didn't
10487
+ * run" is the question this answers, and the answer is usually that the
10488
+ * previous run was still going. A run opens its session — the whole of what
10489
+ * the agent did, not the last paragraph of it.
10490
+ */
10491
+ function routineHistory(routine) {
10492
+ const box = el("details", "routine-history");
10493
+ box.appendChild(el("summary", "muted small", t("routines.history")));
10494
+ const list = el("div", "routine-runs");
10495
+ box.appendChild(list);
10496
+ box.addEventListener("toggle", () => {
10497
+ if (!box.open || list.dataset.loaded) return;
10498
+ list.dataset.loaded = "1";
10499
+ list.appendChild(el("div", "muted small", t("routines.loading")));
10500
+ void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
10501
+ list.replaceChildren();
10502
+ const runs = (body && body.runs) || [];
10503
+ if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
10504
+ for (const run of runs) {
10505
+ const line = el(run.sessionId ? "button" : "div", "routine-run small" + (run.ok || run.skipped ? "" : " failed"));
10506
+ line.appendChild(el("span", null, runSummary(run)));
10507
+ if (run.skipped && run.reason === "still-running") {
10508
+ line.appendChild(el("span", "muted", t("routines.stillRunning")));
10509
+ }
10510
+ if (run.sessionId) {
10511
+ line.type = "button";
10512
+ line.addEventListener("click", () => void openSession(run.sessionId, routine.cwd));
10513
+ }
10514
+ list.appendChild(line);
10515
+ }
10516
+ });
10517
+ });
10518
+ return box;
10519
+ }
10520
+
9906
10521
  /**
9907
10522
  * Scheduled runs, read from the scheduler rather than kept here.
9908
10523
  *
@@ -9917,41 +10532,88 @@ async function showRoutines() {
9917
10532
  const routines = body.routines;
9918
10533
 
9919
10534
  const panel = el("div", "panel");
9920
- panel.appendChild(el("h2", null, t("routines.title")));
10535
+ const top = el("div", "row-head");
10536
+ top.appendChild(el("h2", null, t("routines.title")));
10537
+ const addButton = el("button", "ghost", t("routines.add"));
10538
+ addButton.type = "button";
10539
+ top.appendChild(addButton);
10540
+ panel.appendChild(top);
10541
+
10542
+ addButton.addEventListener("click", () => void newRoutine());
9921
10543
 
9922
10544
  if (!routines || !routines.length) {
9923
10545
  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."));
10546
+ panel.appendChild(el("p", "muted", t("routines.empty")));
9926
10547
  } else {
9927
10548
  for (const routine of routines) {
9928
10549
  const row = el("div", "row-card");
9929
10550
  const head = el("div", "row-head");
9930
10551
  head.appendChild(el("span", "row-title", routine.name));
9931
10552
  head.appendChild(el("span", "mono row-cron", routine.cron));
9932
- if (!routine.enabled) head.appendChild(el("span", "tag", "disabled"));
10553
+ if (!routine.enabled) head.appendChild(el("span", "tag", t("routines.paused")));
10554
+ // Where it runs. A routine is made in one project and runs there, from
10555
+ // whichever project is open when its time comes, so the card says which.
10556
+ if (routine.cwd) {
10557
+ const where = el("span", "muted small routine-where", projectName(routine.cwd));
10558
+ where.title = routine.cwd;
10559
+ head.appendChild(where);
10560
+ }
9933
10561
 
9934
10562
  const run = el("button", "ghost", t("routines.run"));
9935
10563
  run.type = "button";
9936
10564
  run.addEventListener("click", () => {
9937
10565
  run.disabled = true;
9938
10566
  run.textContent = t("routines.running");
9939
- void api("/routine", { name: routine.name }).then(() => showRoutines());
10567
+ void api("/routine", { name: routine.name }).then((result) => {
10568
+ pushNotice(result);
10569
+ void showRoutines();
10570
+ });
9940
10571
  });
9941
10572
  head.appendChild(run);
10573
+ if (routine.removable) {
10574
+ // Paused rather than deleted: the prompt someone got right is kept,
10575
+ // and a routine that should not run this week is one switch away from
10576
+ // running again.
10577
+ const toggle = el("button", "switch");
10578
+ toggle.type = "button";
10579
+ toggle.setAttribute("role", "switch");
10580
+ toggle.setAttribute("aria-checked", routine.enabled ? "true" : "false");
10581
+ const label = routine.enabled ? t("routines.pause") : t("routines.resume");
10582
+ toggle.setAttribute("aria-label", label);
10583
+ toggle.title = label;
10584
+ toggle.addEventListener("click", () => {
10585
+ toggle.disabled = true;
10586
+ void api("/routine/enabled", { name: routine.name, enabled: !routine.enabled }).then((result) => {
10587
+ pushNotice(result);
10588
+ void showRoutines();
10589
+ });
10590
+ });
10591
+ head.appendChild(toggle);
10592
+
10593
+ const remove = el("button", "ghost", t("routines.remove"));
10594
+ remove.type = "button";
10595
+ remove.addEventListener("click", () => {
10596
+ if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
10597
+ remove.disabled = true;
10598
+ void api("/routine/remove", { name: routine.name }).then((result) => {
10599
+ pushNotice(result);
10600
+ void showRoutines();
10601
+ });
10602
+ });
10603
+ head.appendChild(remove);
10604
+ }
9942
10605
  row.appendChild(head);
9943
10606
 
9944
10607
  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) : ""));
10608
+ if (routine.enabled && routine.nextRunAt) {
10609
+ bits.push(t("routines.next", { when: new Date(routine.nextRunAt).toLocaleString() }));
9950
10610
  }
10611
+ if (routine.lastRun) bits.push(runSummary(routine.lastRun));
9951
10612
  if (bits.length) row.appendChild(el("div", "muted small", bits.join(" · ")));
9952
10613
  if (routine.lastRun && routine.lastRun.error) {
9953
10614
  row.appendChild(el("div", "err small", routine.lastRun.error));
9954
10615
  }
10616
+ row.appendChild(routineHistory(routine));
9955
10617
  panel.appendChild(row);
9956
10618
  }
9957
10619
  }
@@ -10445,6 +11107,20 @@ async function showSettings() {
10445
11107
  }),
10446
11108
  );
10447
11109
  }
11110
+ // Only in the desktop app: keeping a computer awake is an Electron call, and
11111
+ // a switch a browser tab cannot honour would be one that lies.
11112
+ const keepAwake = await getJson("/keep-awake");
11113
+ if (keepAwake && !keepAwake.error && keepAwake.available) {
11114
+ target = groups.workspace;
11115
+ field(
11116
+ t("settings.keepAwake"),
11117
+ t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
11118
+ t("settings.keepAwakeHint"),
11119
+ toggle(keepAwake.enabled, t("settings.keepAwake"), (next) => {
11120
+ void api("/keep-awake", { enabled: next }).then(showSettings);
11121
+ }),
11122
+ );
11123
+ }
10448
11124
  const sections = [
10449
11125
  ["settings.section.workspace", [groups.workspace]],
10450
11126
  ["settings.section.sync", [groups.sync, groups.memory]],
@@ -10522,25 +11198,49 @@ async function showPermissions() {
10522
11198
  };
10523
11199
 
10524
11200
  // The mode first, because it decides what the rules below are exceptions to.
11201
+ // Drawn on the scale it sits on, so "workspace-write" reads as the middle
11202
+ // of three rather than as a word to look up.
11203
+ const modes = ["read-only", "workspace-write", "full-access"];
10525
11204
  const modeCard = card(t("perms.mode"));
10526
- modeCard.appendChild(el("div", "mono", workspace.mode || "—"));
11205
+ const modeRow = el("div", "perm-mode");
11206
+ const current = workspace.mode || "";
11207
+ modeRow.appendChild(el("span", "perm-mode-name " + current, current || "—"));
11208
+ if (modes.indexOf(current) >= 0) {
11209
+ const steps = el("div", "perm-mode-steps");
11210
+ for (const mode of modes) steps.appendChild(el("span", "perm-mode-step" + (mode === current ? " on" : ""), mode));
11211
+ modeRow.appendChild(steps);
11212
+ }
11213
+ modeCard.appendChild(modeRow);
11214
+ if (modes.indexOf(current) >= 0) modeCard.appendChild(el("div", "muted small", t("perms.modeHint." + current)));
10527
11215
 
10528
11216
  const rulesCard = card(t("settings.rules"));
10529
11217
  rulesCard.appendChild(el("div", "muted small", t("settings.rulesHint")));
11218
+ const group = (kind, title, items) => {
11219
+ const box = el("div", "perm-group");
11220
+ const head = el("div", "perm-group-head " + kind, title);
11221
+ head.appendChild(el("span", "n", String(items.length)));
11222
+ box.appendChild(head);
11223
+ const chips = el("div", "perm-chips");
11224
+ for (const item of items) {
11225
+ const chip = el("span", "perm-chip " + kind, item);
11226
+ chip.title = item;
11227
+ chips.appendChild(chip);
11228
+ }
11229
+ box.appendChild(chips);
11230
+ rulesCard.appendChild(box);
11231
+ };
10530
11232
  let any = false;
10531
11233
  for (const kind of ["allow", "ask", "deny"]) {
10532
11234
  if (!rules[kind] || !rules[kind].length) continue;
10533
11235
  any = true;
10534
- rulesCard.appendChild(listing(kind + " · " + rules[kind].length, rules[kind].join("\n")));
11236
+ group(kind, t("perms.kind." + kind), rules[kind]);
10535
11237
  }
10536
11238
  const session = rules.sessionAllow || [];
10537
11239
  if (session.length) {
10538
11240
  any = true;
10539
- rulesCard.appendChild(listing(
10540
- t("settings.sessionApproved") + " — " + t("settings.notPersisted"),
10541
- session.join("\n")));
11241
+ group("session", t("settings.sessionApproved") + " — " + t("settings.notPersisted"), session);
10542
11242
  }
10543
- if (!any) rulesCard.appendChild(el("div", "muted small", t("perms.noRules")));
11243
+ if (!any) rulesCard.appendChild(el("div", "perm-empty", t("perms.noRules")));
10544
11244
 
10545
11245
  // The roots, and only where they mean anything: in every other mode the
10546
11246
  // list is not consulted, and an editor for it would be a control that does
@@ -10551,6 +11251,7 @@ async function showPermissions() {
10551
11251
  const derived = all.filter((root) => chosen.indexOf(root) < 0);
10552
11252
 
10553
11253
  const writes = card(t("perms.writes"));
11254
+ writes.previousSibling.appendChild(el("span", "perm-count", String(all.length)));
10554
11255
  writes.appendChild(el("div", "muted small", t("perms.writesHint", { n: all.length })));
10555
11256
 
10556
11257
  // Yours, and editable. A textarea rather than a list with buttons because
@@ -10586,12 +11287,25 @@ async function showPermissions() {
10586
11287
  if (derived.length) {
10587
11288
  // "more" only reads right when there were some above it. With nothing
10588
11289
  // 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));
11290
+ const group = el("div", "setting-list");
11291
+ group.appendChild(el("div", "muted small", chosen.length
11292
+ ? t("perms.derivedMore", { n: derived.length })
11293
+ : t("perms.derivedAll", { n: derived.length })));
11294
+ // The last segment is what tells fifty plugin paths apart; the long
11295
+ // shared prefix is dimmed and gives way first when the line is short.
11296
+ const list = el("div", "perm-roots");
11297
+ for (const root of derived) {
11298
+ const cut = root.replace(/[\\/]+$/, "").search(/[^\\/]+$/);
11299
+ const line = el("div", "perm-root");
11300
+ line.title = root;
11301
+ const dir = el("span", "dir");
11302
+ dir.appendChild(el("bdi", null, cut > 0 ? root.slice(0, cut) : ""));
11303
+ line.appendChild(dir);
11304
+ line.appendChild(el("span", "base", cut > 0 ? root.slice(cut) : root));
11305
+ list.appendChild(line);
11306
+ }
11307
+ group.appendChild(list);
11308
+ writes.appendChild(group);
10595
11309
  }
10596
11310
  }
10597
11311
  }
@@ -10657,25 +11371,48 @@ function auditKind(row) {
10657
11371
  * it does not place anything at all.
10658
11372
  */
10659
11373
  function auditList(rows, withDate) {
10660
- const list = el("div", "mono small audit-list");
11374
+ const list = el("div", "audit-list");
11375
+ let day = "";
10661
11376
  for (const row of rows) {
10662
11377
  const entry = row.entry;
10663
- const line = el("div", "trail-line");
10664
11378
  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);
11379
+ // Across sessions the date is a heading rather than a column: the same
11380
+ // "Sep 15" forty times over is noise that pushes the tool off the line.
11381
+ if (withDate) {
11382
+ const label = at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
11383
+ if (label !== day) {
11384
+ day = label;
11385
+ list.appendChild(el("div", "audit-day", label));
11386
+ }
11387
+ }
11388
+ const kind = auditKind(row);
11389
+ const line = el("div", "trail-line audit-row " + kind);
11390
+ line.appendChild(el("span", "audit-time", at.toLocaleTimeString()));
11391
+ line.appendChild(el("span", "audit-pill " + kind, auditOutcome(row)));
11392
+
11393
+ // "Edit(/a/b/c.md)" reads as a tool and what it touched; drawn as one
11394
+ // string the path is as loud as the verb.
11395
+ const label = entry.principal || entry.tool;
11396
+ const what = el("span", "audit-what");
11397
+ const open = label.indexOf("(");
11398
+ if (open > 0 && label.endsWith(")")) {
11399
+ what.appendChild(el("span", "audit-tool", label.slice(0, open)));
11400
+ const arg = el("span", "audit-arg", label.slice(open + 1, -1));
11401
+ arg.title = label.slice(open + 1, -1);
11402
+ what.appendChild(arg);
11403
+ } else {
11404
+ what.appendChild(el("span", "audit-tool", label));
11405
+ }
11406
+ line.appendChild(what);
11407
+
11408
+ const notes = el("span", "audit-notes");
11409
+ if (entry.rule) notes.appendChild(el("span", "audit-tag", entry.rule));
11410
+ if (row.answer && row.answer.remembered) {
11411
+ notes.appendChild(el("span", "audit-tag remembered", "remembered " + row.answer.remembered));
11412
+ }
10676
11413
  // 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(" · ")));
11414
+ if (entry.behavior === "ask" && !row.answer) notes.appendChild(el("span", "audit-tag unanswered", "unanswered"));
11415
+ line.appendChild(notes);
10679
11416
  list.appendChild(line);
10680
11417
  }
10681
11418
  return list;
@@ -10754,7 +11491,14 @@ async function showAudit() {
10754
11491
  const seg = (options, chosen, onPick) => {
10755
11492
  const box = el("div", "seg");
10756
11493
  for (const option of options) {
10757
- const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""), option.label);
11494
+ const button = el("button", "seg-item" + (option.id === chosen() ? " on" : ""));
11495
+ if (option.color) {
11496
+ const dot = el("span", "seg-dot");
11497
+ dot.style.background = option.color;
11498
+ button.appendChild(dot);
11499
+ }
11500
+ button.appendChild(document.createTextNode(option.label));
11501
+ if (option.count !== undefined) button.appendChild(el("span", "seg-count", String(option.count)));
10758
11502
  button.type = "button";
10759
11503
  button.addEventListener("click", () => {
10760
11504
  if (option.id === chosen()) return;
@@ -10773,8 +11517,15 @@ async function showAudit() {
10773
11517
  (id) => { auditScope = id; void showAudit(); },
10774
11518
  ));
10775
11519
  const filters = ["all", "allowed", "denied", "unanswered"];
11520
+ const rows = auditRows(body.entries);
11521
+ const tally = { all: rows.length, allowed: 0, denied: 0, unanswered: 0 };
11522
+ for (const row of rows) tally[auditKind(row)] += 1;
11523
+ const dots = { allowed: "var(--ok)", denied: "var(--err)", unanswered: "var(--warn)" };
10776
11524
  const filterSeg = seg(
10777
- filters.map((id) => ({ id, label: t("audit.filter." + id) })),
11525
+ filters.map((id) => ({
11526
+ id, label: t("audit.filter." + id), count: tally[id],
11527
+ ...(dots[id] ? { color: dots[id] } : {}),
11528
+ })),
10778
11529
  () => auditFilter,
10779
11530
  (id) => {
10780
11531
  auditFilter = id;
@@ -10787,7 +11538,6 @@ async function showAudit() {
10787
11538
  controls.appendChild(filterSeg);
10788
11539
  panel.appendChild(controls);
10789
11540
 
10790
- const rows = auditRows(body.entries);
10791
11541
  const count = el("div", "muted small");
10792
11542
  panel.appendChild(count);
10793
11543
  const listBox = el("div");
@@ -12221,6 +12971,24 @@ async function loadProjects() {
12221
12971
  if (sessionRows.length) paintSessions();
12222
12972
  }
12223
12973
 
12974
+ async function renameProjectUi(cwd, name) {
12975
+ const next = window.prompt(t("projects.renamePrompt"), name);
12976
+ if (!next || next.trim() === name) return;
12977
+ const body = await api("/project", { path: cwd, rename: next.trim() });
12978
+ if (body && body.error) return void addNotice(body.error, "err");
12979
+ addNotice(t("projects.renamed", { name: body.project.name }));
12980
+ // Paths key tabs, local open/closed state and drag order. Reloading after
12981
+ // the server has rebuilt the renamed harness drops every stale path at once.
12982
+ window.location.reload();
12983
+ }
12984
+
12985
+ function exportProjectUi(cwd) {
12986
+ const link = document.createElement("a");
12987
+ link.href = q("/project/export?path=" + encodeURIComponent(cwd));
12988
+ link.download = "";
12989
+ link.click();
12990
+ }
12991
+
12224
12992
  function openProjectPicker() {
12225
12993
  const dialog = document.getElementById("project-picker");
12226
12994
  const field = document.getElementById("proj-search");
@@ -12247,6 +13015,25 @@ function openProjectPicker() {
12247
13015
 
12248
13016
  const line = el("div", "proj-row");
12249
13017
  line.appendChild(row);
13018
+ const rename = el("button", "proj-forget", "✎");
13019
+ rename.type = "button";
13020
+ rename.title = t("projects.rename");
13021
+ rename.setAttribute("aria-label", t("projects.rename") + ": " + project.name);
13022
+ rename.addEventListener("click", (e) => {
13023
+ e.stopPropagation();
13024
+ dialog.close();
13025
+ void renameProjectUi(project.path, project.name);
13026
+ });
13027
+ line.appendChild(rename);
13028
+ const archive = el("button", "proj-forget", "↓");
13029
+ archive.type = "button";
13030
+ archive.title = t("projects.export");
13031
+ archive.setAttribute("aria-label", t("projects.export") + ": " + project.name);
13032
+ archive.addEventListener("click", (e) => {
13033
+ e.stopPropagation();
13034
+ exportProjectUi(project.path);
13035
+ });
13036
+ line.appendChild(archive);
12250
13037
  // Not offered for the project this window is standing in: the next
12251
13038
  // switch calls addProject and puts it straight back, so the control
12252
13039
  // would appear to do nothing. Nothing recorded is lost either way —
@@ -12415,6 +13202,7 @@ const paletteListEl = document.getElementById("cmdk-list");
12415
13202
  * the same keyboard already has.
12416
13203
  */
12417
13204
  const IS_MAC = /mac/i.test(navigator.platform || navigator.userAgent || "");
13205
+ const IS_WIN = /win/i.test(navigator.platform || navigator.userAgent || "");
12418
13206
 
12419
13207
  /** The rows the current query left on screen, in the order they are drawn. */
12420
13208
  let paletteRows = [];
@@ -12700,6 +13488,53 @@ function paintStarters() {
12700
13488
  }
12701
13489
  }
12702
13490
 
13491
+ /**
13492
+ * The desktop shell's update hint. The state lives in the main process, which
13493
+ * decides whether a release gets a dialog or only this row; the page draws it
13494
+ * and passes the click back, where the right question is asked.
13495
+ */
13496
+ function paintUpdateHint(state) {
13497
+ const box = document.getElementById("update-hint");
13498
+ if (!box) return;
13499
+ const status = state && state.status;
13500
+ if (status !== "available" && status !== "downloading" && status !== "ready") {
13501
+ box.hidden = true;
13502
+ box.textContent = "";
13503
+ return;
13504
+ }
13505
+ box.hidden = false;
13506
+ box.classList.toggle("important", Boolean(state.important));
13507
+ const button = document.createElement("button");
13508
+ button.type = "button";
13509
+ button.disabled = status === "downloading";
13510
+ if (state.important) button.title = t("update.important");
13511
+ const dot = document.createElement("span");
13512
+ dot.className = "dot";
13513
+ const label = document.createElement("span");
13514
+ label.className = "label";
13515
+ label.textContent =
13516
+ status === "ready"
13517
+ ? t("update.ready")
13518
+ : status === "downloading"
13519
+ ? t("update.downloading", { version: state.version })
13520
+ : t("update.available", { version: state.version });
13521
+ button.append(dot, label);
13522
+ if (status === "downloading") {
13523
+ const pct = document.createElement("span");
13524
+ pct.className = "pct";
13525
+ pct.textContent = (state.percent || 0) + "%";
13526
+ button.append(pct);
13527
+ }
13528
+ button.addEventListener("click", () => window.oharness.updates.act());
13529
+ box.replaceChildren(button);
13530
+ }
13531
+ if (IS_DESKTOP && window.oharness.updates) {
13532
+ window.oharness.updates.onState(paintUpdateHint);
13533
+ // Asked once as well: a release found before this page loaded (or before a
13534
+ // reopened window) was announced to nobody.
13535
+ window.oharness.updates.state().then(paintUpdateHint, () => {});
13536
+ }
13537
+
12703
13538
  void (async () => {
12704
13539
  applyStrings();
12705
13540
  paintStarters();
@@ -12834,6 +13669,7 @@ ${ANALYTICS_STUB}
12834
13669
  people look for one. Signing in is what turns on sync and what lets an
12835
13670
  artifact be published, and until now the only door to it was inside the
12836
13671
  model-provider setup — a place nobody goes to find their account. -->
13672
+ <div id="update-hint" hidden></div>
12837
13673
  <div id="account"></div>
12838
13674
  </aside>
12839
13675
  <div id="scrim"></div>
@@ -13070,11 +13906,11 @@ ${ANALYTICS_STUB}
13070
13906
  </div>
13071
13907
  <div class="dlg-actions">
13072
13908
  <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>
13909
+ <button data-answer="once" data-scope="once" class="approve"><span data-t="ask.allow"></span><kbd>↵</kbd></button>
13074
13910
  <button data-answer="session" data-scope="session"><span data-t="ask.session"></span></button>
13075
13911
  <button data-answer="project" data-scope="project"><span data-t="ask.project"></span></button>
13076
13912
  </div>
13077
- <div id="ask-scope" class="ask-scope" hidden></div>
13913
+ <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
13914
  </dialog>
13079
13915
 
13080
13916
  <script>