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