@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/i18n.js +166 -8
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +7 -3
- package/dist/main.js.map +1 -1
- package/dist/markdown.js +6 -4
- package/dist/markdown.js.map +1 -1
- package/dist/model-order.d.ts +64 -0
- package/dist/model-order.js +249 -0
- package/dist/model-order.js.map +1 -0
- package/dist/page.js +942 -106
- package/dist/page.js.map +1 -1
- package/dist/pick.d.ts +13 -0
- package/dist/pick.js +25 -0
- package/dist/pick.js.map +1 -1
- package/dist/prefs.d.ts +5 -0
- package/dist/prefs.js +3 -2
- package/dist/prefs.js.map +1 -1
- package/dist/project-export.d.ts +4 -0
- package/dist/project-export.js +108 -0
- package/dist/project-export.js.map +1 -0
- package/dist/projects.d.ts +8 -0
- package/dist/projects.js +103 -23
- package/dist/projects.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.js +238 -10
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
828
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
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:
|
|
1735
|
-
display:flex;gap:
|
|
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::
|
|
1739
|
-
|
|
1740
|
-
.
|
|
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:
|
|
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
|
-
|
|
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" + (
|
|
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
|
-
|
|
2848
|
-
|
|
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 &&
|
|
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
|
|
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", "
|
|
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
|
|
3450
|
-
|
|
3451
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
4951
|
+
placeRowMenu(anchor);
|
|
4483
4952
|
return;
|
|
4484
4953
|
}
|
|
4485
4954
|
closeRowMenu();
|
|
4486
4955
|
void deleteSession(s.id);
|
|
4487
4956
|
} });
|
|
4488
4957
|
|
|
4489
|
-
openRowMenu(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
5352
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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(() =>
|
|
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.
|
|
9946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
10590
|
-
|
|
10591
|
-
|
|
10592
|
-
|
|
10593
|
-
|
|
10594
|
-
|
|
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", "
|
|
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
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
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.
|
|
10678
|
-
|
|
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" : "")
|
|
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) => ({
|
|
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"
|
|
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>
|