@crewhaus/hangar-ui 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback — the growth loops: the ratings browser with the distill
|
|
3
|
+
* watermark, the few-shot pool, FAQ distill, lessons, the advice feed, and
|
|
4
|
+
* channel reaction state. Plus the fleet-wide rollup screen, which answers
|
|
5
|
+
* "which harnesses have enough feedback to distill".
|
|
6
|
+
*
|
|
7
|
+
* The review QUEUE and its adjudication are the M2 screen (`views/review.js`)
|
|
8
|
+
* and stay there; this is what the ratings turn INTO.
|
|
9
|
+
*
|
|
10
|
+
* Two things this screen refuses to soften:
|
|
11
|
+
* - the watermark travels WITH the unprocessed count. "12 new ratings"
|
|
12
|
+
* without the timestamp it counts from is a guess, and the screen would
|
|
13
|
+
* be inviting a distill run on a number it made up.
|
|
14
|
+
* - the advice feed is ADVISORY. Rendering a proposed SpecPatch is not
|
|
15
|
+
* applying it — apply is a separate gesture that goes back through the
|
|
16
|
+
* spec write path, and a human-owned path routes to `crewhaus propose`.
|
|
17
|
+
*
|
|
18
|
+
* Every node is built with el() — markup strings are banned app-wide, so a
|
|
19
|
+
* user's own rating comment can never inject into this page.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { api } from "../api.js";
|
|
23
|
+
import { clear, collapsible, dot, el, emptyState, mdBlocks, skeleton, toast } from "../dom.js";
|
|
24
|
+
import { fmtCount, fmtPct, fmtRelativeTime } from "../util.js";
|
|
25
|
+
|
|
26
|
+
/** The per-harness Feedback tab. */
|
|
27
|
+
export async function renderFeedback(root, ctx) {
|
|
28
|
+
clear(root).appendChild(skeleton(4));
|
|
29
|
+
const [ratings, fewshot, faq, lessons, advice, reactions] = await Promise.all(
|
|
30
|
+
[api.feedback, api.fewshot, api.faq, api.lessons, api.advice, api.reactions].map((fn) =>
|
|
31
|
+
fn({ id: ctx.id }).catch((err) => ({ ok: false, status: 0, body: { error: String(err) } })),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
clear(root);
|
|
35
|
+
const reload = () => renderFeedback(root, ctx);
|
|
36
|
+
root.appendChild(ratingsCard(ctx, ratings, reload));
|
|
37
|
+
root.appendChild(fewshotCard(ctx, fewshot, reload));
|
|
38
|
+
root.appendChild(faqCard(ctx, faq, reload));
|
|
39
|
+
root.appendChild(lessonsCard(ctx, lessons, reload));
|
|
40
|
+
root.appendChild(adviceCard(ctx, advice, reload));
|
|
41
|
+
root.appendChild(reactionsCard(reactions));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The fleet-wide rollup (`#/feedback`). */
|
|
45
|
+
export async function renderFeedbackBoard(root) {
|
|
46
|
+
clear(root).appendChild(skeleton(4));
|
|
47
|
+
const res = await api
|
|
48
|
+
.feedbackFleet({})
|
|
49
|
+
.catch((err) => ({ ok: false, status: 0, body: { error: String(err) } }));
|
|
50
|
+
clear(root);
|
|
51
|
+
root.appendChild(
|
|
52
|
+
panel("Fleet feedback", res, (card, data) => {
|
|
53
|
+
const ready = new Set(data.distillReady ?? []);
|
|
54
|
+
card.appendChild(
|
|
55
|
+
table(
|
|
56
|
+
["Harness", "Ratings", "Balance", "Unprocessed", "Watermark", "Last rating"],
|
|
57
|
+
(data.harnesses ?? []).map((row) =>
|
|
58
|
+
el("tr", null, [
|
|
59
|
+
el("td", { class: "mono", text: String(row.specName) }),
|
|
60
|
+
el("td", { class: "num", text: fmtCount(row.total) }),
|
|
61
|
+
el("td", null, [
|
|
62
|
+
row.total > 0
|
|
63
|
+
? dot(
|
|
64
|
+
row.up >= row.down ? "ok" : "warn",
|
|
65
|
+
`${fmtCount(row.up)}↑ ${fmtCount(row.down)}↓`,
|
|
66
|
+
)
|
|
67
|
+
: dot("unknown", "none"),
|
|
68
|
+
]),
|
|
69
|
+
el("td", null, [
|
|
70
|
+
ready.has(row.id)
|
|
71
|
+
? dot("warn", `${fmtCount(row.unprocessed)} — distill-ready`)
|
|
72
|
+
: el("span", { class: "num", text: fmtCount(row.unprocessed) }),
|
|
73
|
+
]),
|
|
74
|
+
el("td", { text: fmtRelativeTime(row.watermarkTs, Date.now()) }),
|
|
75
|
+
el("td", { text: fmtRelativeTime(row.lastRatingTs, Date.now()) }),
|
|
76
|
+
]),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
card.appendChild(el("p", { class: "muted", text: String(data.scope ?? "") }));
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// shared bits
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function payload(res) {
|
|
90
|
+
return res && typeof res === "object" && res.body && typeof res.body === "object" ? res.body : {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function panel(title, res, draw) {
|
|
94
|
+
const data = payload(res);
|
|
95
|
+
const card = el("div", { class: "card" }, [
|
|
96
|
+
el("h3", { class: "card-title" }, [el("span", { text: title })]),
|
|
97
|
+
]);
|
|
98
|
+
if (res && res.ok === false && data.present === undefined) {
|
|
99
|
+
card.appendChild(el("p", { class: "muted", text: `unavailable (HTTP ${String(res.status)})` }));
|
|
100
|
+
return card;
|
|
101
|
+
}
|
|
102
|
+
if (data.present === false) {
|
|
103
|
+
card.appendChild(emptyState(String(data.note ?? "Nothing yet"), data.verb ?? null));
|
|
104
|
+
return card;
|
|
105
|
+
}
|
|
106
|
+
if (typeof data.note === "string" && data.note !== "") {
|
|
107
|
+
card.appendChild(el("p", { class: "muted", text: data.note }));
|
|
108
|
+
}
|
|
109
|
+
draw(card, data);
|
|
110
|
+
return card;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function table(headers, rows) {
|
|
114
|
+
return el("div", { class: "table-scroll" }, [
|
|
115
|
+
el("table", { class: "fleet" }, [
|
|
116
|
+
el(
|
|
117
|
+
"thead",
|
|
118
|
+
null,
|
|
119
|
+
el(
|
|
120
|
+
"tr",
|
|
121
|
+
null,
|
|
122
|
+
headers.map((h) => el("th", { text: h })),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
el("tbody", null, rows),
|
|
126
|
+
]),
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stat(label, value) {
|
|
131
|
+
return el("div", { class: "mini" }, [
|
|
132
|
+
el("div", { class: "mini-num", text: value }),
|
|
133
|
+
el("div", { class: "mini-label", text: label }),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function call(fn, params, body) {
|
|
138
|
+
return await fn(params, body).catch((err) => ({
|
|
139
|
+
ok: false,
|
|
140
|
+
status: 0,
|
|
141
|
+
body: { error: String(err) },
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function refusalText(res) {
|
|
146
|
+
const raw = typeof payload(res).error === "string" ? payload(res).error : "";
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
|
|
150
|
+
return `${parsed.error}${parsed.route ? ` — use ${parsed.route}` : ""}`;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// not a structured refusal — fall through to the raw message
|
|
154
|
+
}
|
|
155
|
+
return raw || `HTTP ${String(res.status)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function fire(fn, params, body, okMessage, reload) {
|
|
159
|
+
const res = await call(fn, params, body);
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
toast(refusalText(res), "error");
|
|
162
|
+
return res;
|
|
163
|
+
}
|
|
164
|
+
toast(okMessage, "info");
|
|
165
|
+
if (reload) reload();
|
|
166
|
+
return res;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// ratings + distill
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function ratingsCard(ctx, res, reload) {
|
|
174
|
+
const card = panel("Ratings", res, (node, data) => {
|
|
175
|
+
const watermark = data.watermark ?? {};
|
|
176
|
+
const balance = data.balance ?? {};
|
|
177
|
+
node.appendChild(
|
|
178
|
+
el("div", { class: "mini-row" }, [
|
|
179
|
+
stat("ratings", fmtCount(balance.total)),
|
|
180
|
+
stat(
|
|
181
|
+
"balance",
|
|
182
|
+
balance.total ? `${fmtCount(balance.up)}↑ ${fmtCount(balance.down)}↓` : "—",
|
|
183
|
+
),
|
|
184
|
+
stat("unprocessed", fmtCount(watermark.unprocessed)),
|
|
185
|
+
]),
|
|
186
|
+
);
|
|
187
|
+
// The watermark IS the truth about what distill has consumed. Without it
|
|
188
|
+
// the unprocessed count above would be an assumption, so say which.
|
|
189
|
+
node.appendChild(
|
|
190
|
+
el("p", null, [
|
|
191
|
+
watermark.present
|
|
192
|
+
? dot(
|
|
193
|
+
"ok",
|
|
194
|
+
`watermark ${fmtRelativeTime(watermark.lastProcessedTs, Date.now())} · ${fmtCount(
|
|
195
|
+
watermark.processedCount,
|
|
196
|
+
)} folded`,
|
|
197
|
+
)
|
|
198
|
+
: dot("warn", "no distill watermark — every rating counts as unprocessed"),
|
|
199
|
+
]),
|
|
200
|
+
);
|
|
201
|
+
node.appendChild(
|
|
202
|
+
table(
|
|
203
|
+
["When", "Session · turn", "Rating", "Comment", "Source", ""],
|
|
204
|
+
(data.items ?? []).map((item) =>
|
|
205
|
+
el("tr", null, [
|
|
206
|
+
el("td", { text: fmtRelativeTime(item.ts, Date.now()) }),
|
|
207
|
+
el("td", {
|
|
208
|
+
class: "mono",
|
|
209
|
+
text: `${String(item.sessionId)} · ${String(item.turnNumber)}`,
|
|
210
|
+
}),
|
|
211
|
+
el("td", null, [
|
|
212
|
+
typeof item.score === "number"
|
|
213
|
+
? dot(item.score > 0.5 ? "ok" : "bad", fmtPct(item.score))
|
|
214
|
+
: dot("unknown", "comment"),
|
|
215
|
+
]),
|
|
216
|
+
el("td", { class: "muted", text: String(item.comment || item.correction || "") }),
|
|
217
|
+
el("td", { class: "muted", text: String(item.source) }),
|
|
218
|
+
el("td", { class: "cell-caps" }, [
|
|
219
|
+
item.adjudication === true
|
|
220
|
+
? el("span", { class: "chip chip-group", text: "adjudicated" })
|
|
221
|
+
: null,
|
|
222
|
+
item.unprocessed === true ? el("span", { class: "chip", text: "new" }) : null,
|
|
223
|
+
]),
|
|
224
|
+
]),
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
if (Array.isArray(data.sinks) && data.sinks.length > 0) {
|
|
229
|
+
node.appendChild(el("p", { class: "muted mono", text: data.sinks.join(" · ") }));
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const judge = el("input", { type: "checkbox", name: "judge" });
|
|
234
|
+
const register = el("input", { type: "checkbox", name: "register" });
|
|
235
|
+
const go = el("button", { class: "btn btn-primary", type: "button", text: "Distill now" });
|
|
236
|
+
go.addEventListener("click", async () => {
|
|
237
|
+
go.disabled = true;
|
|
238
|
+
await fire(
|
|
239
|
+
api.distillRun,
|
|
240
|
+
{ id: ctx.id },
|
|
241
|
+
{ judge: judge.checked, register: register.checked },
|
|
242
|
+
register.checked ? "Distill queued — result will be registered" : "Distill queued",
|
|
243
|
+
reload,
|
|
244
|
+
);
|
|
245
|
+
go.disabled = false;
|
|
246
|
+
});
|
|
247
|
+
card.appendChild(
|
|
248
|
+
el("div", { class: "editor-actions" }, [
|
|
249
|
+
el("label", { class: "check" }, [judge, el("span", { text: " use the judge" })]),
|
|
250
|
+
el("label", { class: "check" }, [
|
|
251
|
+
register,
|
|
252
|
+
el("span", { text: " promote into the dataset registry (--register)" }),
|
|
253
|
+
]),
|
|
254
|
+
go,
|
|
255
|
+
]),
|
|
256
|
+
);
|
|
257
|
+
card.appendChild(
|
|
258
|
+
el("p", {
|
|
259
|
+
class: "muted",
|
|
260
|
+
text: "This runs the distill verb. It does not enable the spec's autoDistill, which the shipped runtime honours only from the CLI.",
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
return card;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// the growers
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
function fewshotCard(ctx, res, reload) {
|
|
271
|
+
const card = panel("Few-shot pool", res, (node, data) => {
|
|
272
|
+
node.appendChild(
|
|
273
|
+
table(
|
|
274
|
+
["Input", "Golden answer", "Score", "Source", "Pool"],
|
|
275
|
+
(data.entries ?? []).map((entry) =>
|
|
276
|
+
el("tr", null, [
|
|
277
|
+
el("td", { text: String(entry.input) }),
|
|
278
|
+
el("td", { class: "muted", text: String(entry.output) }),
|
|
279
|
+
el("td", {
|
|
280
|
+
class: "num",
|
|
281
|
+
text: typeof entry.score === "number" ? fmtPct(entry.score) : "—",
|
|
282
|
+
}),
|
|
283
|
+
el("td", { class: "muted", text: String(entry.source ?? "") }),
|
|
284
|
+
el("td", { class: "cell-caps" }, [
|
|
285
|
+
entry.inUse === true
|
|
286
|
+
? el("span", {
|
|
287
|
+
class: "chip chip-group",
|
|
288
|
+
title: "injected by this spec",
|
|
289
|
+
text: "in use",
|
|
290
|
+
})
|
|
291
|
+
: el("span", { class: "chip", text: String(entry.file) }),
|
|
292
|
+
]),
|
|
293
|
+
]),
|
|
294
|
+
),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
const harvest = el("button", { class: "btn btn-ghost", type: "button", text: "Preview harvest" });
|
|
299
|
+
const holder = el("div");
|
|
300
|
+
harvest.addEventListener("click", async () => {
|
|
301
|
+
harvest.disabled = true;
|
|
302
|
+
const res2 = await call(api.fewshotHarvest, { id: ctx.id }, {});
|
|
303
|
+
harvest.disabled = false;
|
|
304
|
+
clear(holder);
|
|
305
|
+
if (!res2.ok) {
|
|
306
|
+
toast(refusalText(res2), "error");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const data = payload(res2);
|
|
310
|
+
holder.appendChild(el("p", { class: "gated-why", text: String(data.note ?? "") }));
|
|
311
|
+
holder.appendChild(el("p", { class: "muted mono", text: (data.writes ?? []).join(" · ") }));
|
|
312
|
+
const run = el("button", { class: "btn btn-danger", type: "button", text: "Harvest now" });
|
|
313
|
+
run.addEventListener("click", async () => {
|
|
314
|
+
run.disabled = true;
|
|
315
|
+
await fire(api.fewshotHarvest, { id: ctx.id }, { confirm: true }, "Harvest queued", reload);
|
|
316
|
+
run.disabled = false;
|
|
317
|
+
});
|
|
318
|
+
holder.appendChild(el("div", { class: "editor-actions" }, [run]));
|
|
319
|
+
});
|
|
320
|
+
card.appendChild(el("div", { class: "editor-actions" }, [harvest]));
|
|
321
|
+
card.appendChild(holder);
|
|
322
|
+
return card;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function faqCard(ctx, res, reload) {
|
|
326
|
+
const card = panel("Auto-discovered FAQ skill", res, (node, data) => {
|
|
327
|
+
node.appendChild(el("p", { class: "muted mono", text: String(data.path ?? "") }));
|
|
328
|
+
// Rendered, not summarized: a generated skill the operator cannot read is
|
|
329
|
+
// a behaviour change nobody approved.
|
|
330
|
+
node.appendChild(mdBlocks(String(data.skill ?? "")));
|
|
331
|
+
});
|
|
332
|
+
const distill = el("button", { class: "btn btn-ghost", type: "button", text: "Distill FAQ" });
|
|
333
|
+
distill.addEventListener("click", async () => {
|
|
334
|
+
distill.disabled = true;
|
|
335
|
+
await fire(api.faqDistill, { id: ctx.id }, {}, "FAQ distill queued", reload);
|
|
336
|
+
distill.disabled = false;
|
|
337
|
+
});
|
|
338
|
+
card.appendChild(el("div", { class: "editor-actions" }, [distill]));
|
|
339
|
+
return card;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function lessonsCard(ctx, res, reload) {
|
|
343
|
+
const card = panel("Lessons + preferences", res, (node, data) => {
|
|
344
|
+
if (data.lessons) {
|
|
345
|
+
node.appendChild(
|
|
346
|
+
collapsible(
|
|
347
|
+
[
|
|
348
|
+
el("span", { text: "LESSONS.md" }),
|
|
349
|
+
el("span", {
|
|
350
|
+
class: "muted",
|
|
351
|
+
text: ` updated ${fmtRelativeTime(data.lessonsUpdatedAt, Date.now())}`,
|
|
352
|
+
}),
|
|
353
|
+
],
|
|
354
|
+
[mdBlocks(String(data.lessons))],
|
|
355
|
+
true,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
for (const pref of data.preferences ?? []) {
|
|
360
|
+
node.appendChild(
|
|
361
|
+
collapsible(
|
|
362
|
+
[el("span", { class: "mono", text: `preferences/${String(pref.user)}` })],
|
|
363
|
+
[mdBlocks(String(pref.body))],
|
|
364
|
+
false,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
const holder = el("div");
|
|
370
|
+
const update = el("button", { class: "btn btn-ghost", type: "button", text: "Preview update" });
|
|
371
|
+
update.addEventListener("click", async () => {
|
|
372
|
+
update.disabled = true;
|
|
373
|
+
const res2 = await call(api.lessonsUpdate, { id: ctx.id }, {});
|
|
374
|
+
update.disabled = false;
|
|
375
|
+
clear(holder);
|
|
376
|
+
if (!res2.ok) {
|
|
377
|
+
toast(refusalText(res2), "error");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
holder.appendChild(el("p", { class: "gated-why", text: String(payload(res2).note ?? "") }));
|
|
381
|
+
const run = el("button", { class: "btn btn-danger", type: "button", text: "Update lessons" });
|
|
382
|
+
run.addEventListener("click", async () => {
|
|
383
|
+
run.disabled = true;
|
|
384
|
+
await fire(
|
|
385
|
+
api.lessonsUpdate,
|
|
386
|
+
{ id: ctx.id },
|
|
387
|
+
{ confirm: true },
|
|
388
|
+
"Lessons update queued",
|
|
389
|
+
reload,
|
|
390
|
+
);
|
|
391
|
+
run.disabled = false;
|
|
392
|
+
});
|
|
393
|
+
holder.appendChild(el("div", { class: "editor-actions" }, [run]));
|
|
394
|
+
});
|
|
395
|
+
card.appendChild(el("div", { class: "editor-actions" }, [update]));
|
|
396
|
+
card.appendChild(holder);
|
|
397
|
+
return card;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// the advisory feed
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
function adviceCard(ctx, res, reload) {
|
|
405
|
+
const card = panel("Advice", res, (node, data) => {
|
|
406
|
+
node.appendChild(
|
|
407
|
+
el("p", {
|
|
408
|
+
class: "muted",
|
|
409
|
+
text: "Advisory only. Applying an auto-tunable proposal writes the spec through the same restriction the optimizer runs under; a human-owned path is refused and routed to crewhaus propose.",
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
for (const proposal of data.proposals ?? []) {
|
|
413
|
+
node.appendChild(proposalRow(ctx, proposal, reload));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
const mine = el("button", {
|
|
417
|
+
class: "btn btn-ghost",
|
|
418
|
+
type: "button",
|
|
419
|
+
text: "Mine sessions for advice",
|
|
420
|
+
});
|
|
421
|
+
mine.addEventListener("click", async () => {
|
|
422
|
+
mine.disabled = true;
|
|
423
|
+
await fire(api.adviceRun, { id: ctx.id }, {}, "Advise queued (proposals only)", reload);
|
|
424
|
+
mine.disabled = false;
|
|
425
|
+
});
|
|
426
|
+
card.appendChild(el("div", { class: "editor-actions" }, [mine]));
|
|
427
|
+
return card;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function proposalRow(ctx, proposal, reload) {
|
|
431
|
+
const auto = proposal.tier === "auto-tunable";
|
|
432
|
+
const holder = el("div");
|
|
433
|
+
const previewBtn = el("button", { class: "btn btn-ghost", type: "button", text: "Preview diff" });
|
|
434
|
+
previewBtn.addEventListener("click", async () => {
|
|
435
|
+
previewBtn.disabled = true;
|
|
436
|
+
const res = await call(
|
|
437
|
+
api.adviceApply,
|
|
438
|
+
{ id: ctx.id, adviceId: String(proposal.adviceId) },
|
|
439
|
+
{},
|
|
440
|
+
);
|
|
441
|
+
previewBtn.disabled = false;
|
|
442
|
+
clear(holder);
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
// A human-owned refusal is a FIRST-CLASS state: show the diff it would
|
|
445
|
+
// have made and name the route that can carry it.
|
|
446
|
+
const raw = typeof payload(res).error === "string" ? payload(res).error : "";
|
|
447
|
+
let parsed = null;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(raw);
|
|
450
|
+
} catch {
|
|
451
|
+
parsed = null;
|
|
452
|
+
}
|
|
453
|
+
if (parsed && typeof parsed === "object") {
|
|
454
|
+
holder.appendChild(el("p", { class: "gated-why", text: String(parsed.error) }));
|
|
455
|
+
holder.appendChild(diffList(parsed.diff ?? []));
|
|
456
|
+
holder.appendChild(
|
|
457
|
+
el("p", { class: "muted" }, [
|
|
458
|
+
el("span", { text: "This path is human-owned — route it through " }),
|
|
459
|
+
el("code", { text: String(parsed.route ?? "crewhaus propose") }),
|
|
460
|
+
]),
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
toast(refusalText(res), "error");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const data = payload(res);
|
|
468
|
+
if (data.present === false) {
|
|
469
|
+
holder.appendChild(emptyState(String(data.note), data.verb ?? null));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
holder.appendChild(diffList(data.diff ?? []));
|
|
473
|
+
const apply = el("button", {
|
|
474
|
+
class: "btn btn-danger",
|
|
475
|
+
type: "button",
|
|
476
|
+
text: "Apply to the spec",
|
|
477
|
+
});
|
|
478
|
+
apply.addEventListener("click", async () => {
|
|
479
|
+
apply.disabled = true;
|
|
480
|
+
await fire(
|
|
481
|
+
api.adviceApply,
|
|
482
|
+
{ id: ctx.id, adviceId: String(proposal.adviceId) },
|
|
483
|
+
{ confirm: true },
|
|
484
|
+
"Applied to crewhaus.yaml",
|
|
485
|
+
reload,
|
|
486
|
+
);
|
|
487
|
+
apply.disabled = false;
|
|
488
|
+
});
|
|
489
|
+
holder.appendChild(el("div", { class: "editor-actions" }, [apply]));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return el("div", { class: "inbox-card" }, [
|
|
493
|
+
el("div", { class: "inbox-head" }, [
|
|
494
|
+
dot(auto ? "ok" : "warn", proposal.tier ?? "unknown"),
|
|
495
|
+
el("span", { class: "mono", text: ` ${(proposal.path ?? []).join(".")}` }),
|
|
496
|
+
el("span", { class: "chip", text: String(proposal.severity ?? "info") }),
|
|
497
|
+
]),
|
|
498
|
+
el("p", { text: String(proposal.summary ?? "") }),
|
|
499
|
+
proposal.rationale ? el("p", { class: "reason", text: String(proposal.rationale) }) : null,
|
|
500
|
+
el("p", { class: "gated-why", text: String(proposal.tierReason ?? "") }),
|
|
501
|
+
el("div", { class: "inbox-actions" }, [previewBtn]),
|
|
502
|
+
holder,
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function diffList(entries) {
|
|
507
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
508
|
+
return el("p", { class: "muted", text: "no textual change" });
|
|
509
|
+
}
|
|
510
|
+
return el(
|
|
511
|
+
"ul",
|
|
512
|
+
{ class: "check-list" },
|
|
513
|
+
entries.map((d) =>
|
|
514
|
+
el("li", { class: "mono" }, [
|
|
515
|
+
el("span", { text: String(d.path ?? "") }),
|
|
516
|
+
el("span", {
|
|
517
|
+
class: "muted",
|
|
518
|
+
text: ` ${String(d.before ?? "—")} → ${String(d.after ?? "—")}`,
|
|
519
|
+
}),
|
|
520
|
+
]),
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// channel reactions
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
/** The precondition is the point: under a `thread` sessionKey a reaction has
|
|
530
|
+
* no session to attach to and the loop collects NOTHING. A panel showing a
|
|
531
|
+
* bare zero would be indistinguishable from a quiet week. */
|
|
532
|
+
function reactionsCard(res) {
|
|
533
|
+
return panel("Channel reactions", res, (card, data) => {
|
|
534
|
+
card.appendChild(
|
|
535
|
+
el("p", null, [
|
|
536
|
+
data.collecting === true
|
|
537
|
+
? dot("ok", `collecting (sessionKey: ${String(data.sessionKeyMode)})`)
|
|
538
|
+
: dot("warn", "collecting nothing"),
|
|
539
|
+
]),
|
|
540
|
+
);
|
|
541
|
+
if (data.caveat) card.appendChild(el("p", { class: "gated-why", text: String(data.caveat) }));
|
|
542
|
+
card.appendChild(
|
|
543
|
+
table(
|
|
544
|
+
["When", "Session · turn", "Reaction"],
|
|
545
|
+
(data.reactions ?? []).map((r) =>
|
|
546
|
+
el("tr", null, [
|
|
547
|
+
el("td", { text: fmtRelativeTime(r.ts, Date.now()) }),
|
|
548
|
+
el("td", { class: "mono", text: `${String(r.sessionId)} · ${String(r.turnNumber)}` }),
|
|
549
|
+
el("td", null, [dot(r.thumbs === "up" ? "ok" : "bad", String(r.thumbs ?? "—"))]),
|
|
550
|
+
]),
|
|
551
|
+
),
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
}
|