@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50

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.
Files changed (102) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4833 -2752
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +165 -48
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +236 -15
  51. package/templates/ops/agents.mjs +7 -3
  52. package/templates/ops/compose.mjs +31 -3
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +43 -4
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_inflight.md +14 -0
  57. package/templates/ops/prompts/_paths.md +2 -1
  58. package/templates/ops/prompts/daily.md +37 -9
  59. package/templates/ops/prompts/mention.md +21 -0
  60. package/templates/ops/run-record.mjs +146 -0
  61. package/templates/portal/css/base.css +167 -73
  62. package/templates/portal/css/brain.css +23 -20
  63. package/templates/portal/css/diff.css +10 -9
  64. package/templates/portal/css/graph.css +12 -7
  65. package/templates/portal/css/health.css +13 -11
  66. package/templates/portal/css/home.css +93 -0
  67. package/templates/portal/css/inbox.css +45 -25
  68. package/templates/portal/css/layout.css +90 -46
  69. package/templates/portal/css/markdown.css +36 -14
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +116 -34
  72. package/templates/portal/index.html +21 -9
  73. package/templates/portal/js/api.js +44 -4
  74. package/templates/portal/js/app.js +94 -9
  75. package/templates/portal/js/dialog.js +83 -0
  76. package/templates/portal/js/homesort.js +174 -0
  77. package/templates/portal/js/icons.js +37 -0
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/md.js +5 -2
  80. package/templates/portal/js/mdedit.js +84 -0
  81. package/templates/portal/js/readiness.js +70 -0
  82. package/templates/portal/js/refresh.js +10 -2
  83. package/templates/portal/js/state.js +11 -5
  84. package/templates/portal/js/views/app.js +24 -7
  85. package/templates/portal/js/views/brain.js +1 -1
  86. package/templates/portal/js/views/checklist.js +10 -4
  87. package/templates/portal/js/views/credential.js +84 -0
  88. package/templates/portal/js/views/graph.js +1 -1
  89. package/templates/portal/js/views/health.js +17 -4
  90. package/templates/portal/js/views/hire.js +593 -0
  91. package/templates/portal/js/views/home.js +546 -0
  92. package/templates/portal/js/views/inbox.js +226 -70
  93. package/templates/portal/js/views/org.js +46 -106
  94. package/templates/portal/js/views/orgedit.js +234 -0
  95. package/templates/portal/js/views/paste.js +87 -21
  96. package/templates/portal/js/views/prompt.js +11 -4
  97. package/templates/portal/js/views/repos.js +20 -15
  98. package/templates/portal/js/views/runonce.js +94 -0
  99. package/templates/portal/js/views/runs.js +170 -0
  100. package/templates/portal/js/views/setup.js +261 -75
  101. package/templates/portal/js/views/staff.js +170 -243
  102. package/templates/portal/js/views/todo.js +62 -0
@@ -0,0 +1,546 @@
1
+ /* Home: what needs you, who is working, what you asked for, and what was closed today.
2
+ *
3
+ * Built from the inbox list and /api/live, sorted by homesort.js, which is where the rule that
4
+ * every open item has exactly one place lives. Clicking an item opens its thread in a side
5
+ * sheet, the same thread view Trackers has, with the reply box in it. */
6
+
7
+ import { getLive, post } from "../api.js";
8
+ import { sheet } from "../dialog.js";
9
+ import { ago, el, skeleton } from "../dom.js";
10
+ import { daysUntil, sortHome } from "../homesort.js";
11
+ import { icon, iconHTML, staffIcon } from "../icons.js";
12
+ import { ensureInbox, stampCounts } from "../refresh.js";
13
+ import { go, render } from "../router.js";
14
+ import { threadInto } from "./inbox.js";
15
+ import { humansOf, S } from "../state.js";
16
+
17
+ const KIND_LABEL = {
18
+ decision: "Decision",
19
+ review: "Review",
20
+ chore: "Chore",
21
+ ask: "Ask",
22
+ merge: "Pull request",
23
+ };
24
+ const KIND_TONE = { decision: "hot", review: "warm", chore: "cool", ask: "", merge: "cool" };
25
+
26
+ /* While a run is live this asks every few seconds; otherwise every half minute, so a run that
27
+ a reply just started shows up without a reload. Only while Home is on screen. */
28
+ const FAST = 5_000;
29
+ const SLOW = 30_000;
30
+ /** How old the issues on Home may get while it is open before they are read again. */
31
+ const ISSUES_STALE = 120_000;
32
+ let timer = null;
33
+ let issuesAt = Date.now();
34
+
35
+ export function viewHome(m) {
36
+ m.append(el("h1", { textContent: "Home" }));
37
+
38
+ if (!S.inbox) {
39
+ m.append(el("div", { className: "home" }, skeleton("row", 5)));
40
+ ensureInbox(false)
41
+ .then(() => S.view === "home" && render())
42
+ .catch((e) => {
43
+ m.append(el("p", { className: "err", textContent: String(e.message || e) }));
44
+ });
45
+ poll();
46
+ return;
47
+ }
48
+ if (!S.live) poll();
49
+
50
+ const staff = S.data.staff;
51
+ const h = sortHome(S.inbox.items ?? [], staff, humansOf(), S.live?.staff ?? []);
52
+ stampCounts();
53
+
54
+ const page = el("div", { className: "home" });
55
+ page.append(askBox(staff));
56
+
57
+ if (h.reports.length) {
58
+ page.append(section("Latest reports"));
59
+ const box = el("div", { className: "card homereports" });
60
+ for (const r of h.reports) {
61
+ box.append(
62
+ el("div", { className: "homereport" }, [
63
+ el("div", { className: "homewho2" }, [
64
+ icon(staffIcon(r.staff), "ic"),
65
+ el("b", { textContent: r.staff.name }),
66
+ el("span", { className: "meta", textContent: ago(r.at) }),
67
+ ]),
68
+ el("div", {}, r.lines.map((l) => el("p", { textContent: l }))),
69
+ ]),
70
+ );
71
+ }
72
+ page.append(box);
73
+ }
74
+
75
+ page.append(section("Needs you", h.needs.length));
76
+ if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
77
+ else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
78
+
79
+ if (h.unread.length) {
80
+ page.append(section("Unread", h.unread.length));
81
+ page.append(listBox(h.unread.map(unreadRow)));
82
+ }
83
+
84
+ page.append(section("Working now"));
85
+ page.append(working(staff));
86
+
87
+ if (h.requests.length) {
88
+ page.append(section("Your requests", h.requests.length));
89
+ page.append(listBox(h.requests.map(requestRow)));
90
+ }
91
+
92
+ if (h.closedToday.length) {
93
+ page.append(section("Closed today", h.closedToday.length));
94
+ page.append(listBox(h.closedToday.map(closedRow)));
95
+ }
96
+
97
+ page.append(rest(h));
98
+ for (const e of S.inbox.errors ?? []) page.append(el("p", { className: "err", textContent: e }));
99
+ m.append(page);
100
+ }
101
+
102
+ /* ------------------------------- asking -------------------------------- */
103
+
104
+ function askBox(staff) {
105
+ const who = el("select", { className: "homewho", ariaLabel: "Who to ask" });
106
+ for (const s of staff.filter((x) => x.brain)) {
107
+ who.append(el("option", { value: s.handle, textContent: s.name }));
108
+ }
109
+ const text = el("textarea", {
110
+ className: "homeask",
111
+ rows: 2,
112
+ placeholder: "Ask a staff member to do something",
113
+ });
114
+ const send = el("button", { className: "ghbtn primary", textContent: "Send" });
115
+ const note = el("span", { className: "meta" });
116
+ send.onclick = async () => {
117
+ const s = staff.find((x) => x.handle === who.value);
118
+ const said = text.value.trim();
119
+ if (!s || !said) return;
120
+ send.disabled = true;
121
+ note.textContent = "Sending…";
122
+ try {
123
+ await post({
124
+ action: "create",
125
+ repo: s.brain,
126
+ title: titleOf(said),
127
+ body: `${s.mention ?? "@" + s.handle} ${said}`,
128
+ });
129
+ text.value = "";
130
+ note.textContent = `Sent. ${s.name} starts in about a minute.`;
131
+ refreshSoon();
132
+ } catch (e) {
133
+ note.textContent = String(e.message || e);
134
+ note.className = "meta err";
135
+ } finally {
136
+ send.disabled = false;
137
+ }
138
+ };
139
+ return el("div", { className: "card homeaskcard" }, [
140
+ el("div", { className: "row" }, [who]),
141
+ text,
142
+ el("div", { className: "row" }, [send, note]),
143
+ ]);
144
+ }
145
+
146
+ /** The first line, short enough to be an issue title. */
147
+ function titleOf(said) {
148
+ const line = said.split("\n").find((l) => l.trim()) ?? said;
149
+ return line.length > 90 ? line.slice(0, 89).trimEnd() + "…" : line;
150
+ }
151
+
152
+ /* ------------------------------ needs you ------------------------------- */
153
+
154
+ function needCard(e) {
155
+ const { item, kind } = e;
156
+ /* The whole card opens the thread; the one quick action on it does not. Answering in words
157
+ happens in the thread, where what was asked is in front of you. */
158
+ const card = el("div", {
159
+ className: "card homecard" + (item.unread ? " unread" : ""),
160
+ tabIndex: 0,
161
+ role: "button",
162
+ });
163
+ card.onclick = (ev) => {
164
+ if (!ev.target.closest("button, a, textarea")) openThread(item);
165
+ };
166
+ card.onkeydown = (ev) => {
167
+ if (ev.target === card && (ev.key === "Enter" || ev.key === " ")) {
168
+ ev.preventDefault();
169
+ openThread(item);
170
+ }
171
+ };
172
+ // What it is on the left, what you can do about it on the right.
173
+ const about = el("div", { className: "homeabout" }, [
174
+ el("div", { className: "homehead" }, [
175
+ el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
176
+ openLink(item),
177
+ ]),
178
+ el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
179
+ ]);
180
+ card.append(about);
181
+
182
+ if (kind === "decision" && item.due) about.append(dueLine(item.due));
183
+ if (kind === "merge") about.append(checksLine(item));
184
+
185
+ // The note comes first so that what happened sits just left of the button that did it.
186
+ const note = el("span", { className: "meta" });
187
+ const actions = el("div", { className: "row homeacts" }, [note]);
188
+
189
+ if (kind === "decision" || kind === "ask") {
190
+ actions.append(button("Reply", true, () => openThread(item)));
191
+ if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
192
+ } else if (kind === "review") {
193
+ actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
194
+ } else if (kind === "chore") {
195
+ actions.append(button("Done", true, () => done(e, note, actions)));
196
+ } else if (kind === "merge") {
197
+ const blocked = item.checks === "failing" || item.mergeable === "CONFLICTING";
198
+ const merge = button("Merge", true, () => merge_(e, note, actions));
199
+ merge.disabled = blocked || item.checks === "pending";
200
+ actions.append(merge);
201
+ }
202
+ card.append(actions);
203
+ return card;
204
+ }
205
+
206
+ function dueLine(due) {
207
+ const d = daysUntil(due.date);
208
+ const when =
209
+ d > 1 ? `in ${d} days` : d === 1 ? "tomorrow" : d === 0 ? "today" : `${-d} day${d === -1 ? "" : "s"} ago`;
210
+ return el("p", {
211
+ className: "homedue" + (d <= 0 ? " late" : ""),
212
+ textContent: `Default ${d < 0 ? "was due" : "applies"} ${when}: ${due.action}.`,
213
+ });
214
+ }
215
+
216
+ function checksLine(item) {
217
+ const words = {
218
+ passing: "Checks pass.",
219
+ failing: "Checks fail.",
220
+ pending: "Checks are running.",
221
+ none: "No checks.",
222
+ };
223
+ const conflict = item.mergeable === "CONFLICTING" ? " Conflicts with its base." : "";
224
+ return el("p", {
225
+ className: "homedue" + (item.checks === "failing" || conflict ? " late" : ""),
226
+ textContent: (words[item.checks] ?? "") + conflict,
227
+ });
228
+ }
229
+
230
+ async function say(e, text, note, actions) {
231
+ const said = String(text ?? "").trim();
232
+ if (!said) return;
233
+ const mention = e.staff?.mention ?? "";
234
+ await act(note, actions, { action: "comment", repo: e.item.repo, number: e.item.number, body: `${mention} ${said}`.trim() }, "Sent.");
235
+ }
236
+
237
+ function done(e, note, actions) {
238
+ return act(note, actions, { action: "close", repo: e.item.repo, number: e.item.number, body: "Done." }, "Closed.");
239
+ }
240
+
241
+ function merge_(e, note, actions) {
242
+ return act(note, actions, { action: "merge", repo: e.item.repo, number: e.item.number }, "Merged.");
243
+ }
244
+
245
+ async function act(note, actions, payload, ok) {
246
+ for (const b of actions.querySelectorAll("button")) b.disabled = true;
247
+ note.className = "meta";
248
+ note.textContent = "Working…";
249
+ try {
250
+ await post(payload);
251
+ note.textContent = ok;
252
+ // Done is done: the card goes now, rather than when GitHub next answers.
253
+ const card = actions.closest?.(".homecard, .homeline");
254
+ if (card) {
255
+ card.classList.add("gone");
256
+ setTimeout(() => card.remove(), 220);
257
+ }
258
+ refreshSoon();
259
+ } catch (err) {
260
+ note.textContent = String(err.message || err);
261
+ note.className = "meta err";
262
+ for (const b of actions.querySelectorAll("button")) b.disabled = false;
263
+ }
264
+ }
265
+
266
+ /* ------------------------------ working now ----------------------------- */
267
+
268
+ /** What a run was, in a few words: "daily run", "answering #12", "a peer's ask #4". */
269
+ function whatRun(r) {
270
+ if (r.trigger === "mention") return r.issue ? `answering #${r.issue}` : "answering a mention";
271
+ if (r.trigger === "peer") return r.issue ? `a peer's ask #${r.issue}` : "a peer's ask";
272
+ return (
273
+ { daily: "daily run", manual: "run started by hand", "follow-on": "follow-on run" }[r.trigger] ??
274
+ "run"
275
+ );
276
+ }
277
+
278
+ function working(staff) {
279
+ const box = el("div", { className: "card homeworking" });
280
+ if (!S.live) {
281
+ box.append(el("p", { className: "homerunwhat", textContent: "Asking GitHub…" }));
282
+ return box;
283
+ }
284
+ for (const s of staff) {
285
+ const l = (S.live.staff ?? []).find((x) => x.handle === s.handle);
286
+ const runs = l && !l.error ? (l.running ?? []) : [];
287
+ const busy = runs.length > 0;
288
+ const last = l?.finished?.[0];
289
+
290
+ const what = el("div", { className: "homerunwhat" });
291
+ let log = null;
292
+ if (!l || l.error) {
293
+ what.append(el("span", { className: "err", textContent: l?.error ?? "Could not read their runs." }));
294
+ } else if (busy) {
295
+ for (const r of runs) {
296
+ what.append(el("div", { textContent: `${cap(whatRun(r))} · ${minutes(r.createdAt)} so far` }));
297
+ }
298
+ log = runs[0].url;
299
+ } else if (last) {
300
+ what.append(
301
+ el("div", { textContent: `${cap(whatRun(last))}, ${outcome(last)} ${ago(last.updatedAt)}` }),
302
+ );
303
+ log = last.url;
304
+ } else {
305
+ what.append(el("div", { textContent: "No runs in the last few hours." }));
306
+ }
307
+ if (l && !l.error && l.automatic) {
308
+ what.append(
309
+ el("div", {
310
+ className: "homerunsub",
311
+ textContent: `${l.automatic} of ${l.limit} runs today that nobody asked for`,
312
+ }),
313
+ );
314
+ }
315
+
316
+ const state = el("span", {
317
+ className: "chip homestate " + (busy ? "ok" : ""),
318
+ textContent: busy ? (runs[0].status === "queued" ? "Queued" : "Working") : "Idle",
319
+ });
320
+ const actions = el("div", { className: "homerunlog" });
321
+ if (log) {
322
+ actions.append(
323
+ el("a", { className: "ghbtn", href: log, target: "_blank", rel: "noopener", textContent: "Log" }),
324
+ );
325
+ }
326
+
327
+ box.append(
328
+ el("div", { className: "homerun" + (busy ? " busy" : "") }, [
329
+ el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]),
330
+ state,
331
+ what,
332
+ actions,
333
+ ]),
334
+ );
335
+ }
336
+ return box;
337
+ }
338
+
339
+ const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
340
+
341
+ function minutes(since) {
342
+ const m = Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / 60000));
343
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
344
+ }
345
+
346
+ function outcome(r) {
347
+ return r.conclusion === "success" ? "finished" : r.conclusion === "failure" ? "failed" : (r.conclusion ?? "ended");
348
+ }
349
+
350
+ /**
351
+ * A timer that never keeps a process alive. In the browser this is setTimeout; under the
352
+ * tests' Node shim the polling loop would otherwise hold a test file open forever.
353
+ */
354
+ function later(fn, ms) {
355
+ const t = setTimeout(fn, ms);
356
+ t?.unref?.();
357
+ return t;
358
+ }
359
+
360
+ function poll() {
361
+ clearTimeout(timer);
362
+ getLive()
363
+ .then((data) => {
364
+ // Only a change repaints: a poll every few seconds must not move the page under you.
365
+ const changed = JSON.stringify(data.staff) !== JSON.stringify(S.live?.staff);
366
+ /* A run that just ended has usually replied, closed or opened something, so that is when
367
+ the issues are read again. Otherwise every couple of minutes while Home is open. */
368
+ const ended = S.live && finishedCount(data) !== finishedCount(S.live);
369
+ S.live = data;
370
+ if (ended || Date.now() - issuesAt > ISSUES_STALE) {
371
+ issuesAt = Date.now();
372
+ refreshSoon(false);
373
+ } else if (changed && S.view === "home" && !document.querySelector("dialog[open]")) {
374
+ repaintInPlace();
375
+ }
376
+ })
377
+ .catch(() => {})
378
+ .finally(() => {
379
+ if (S.view !== "home") return;
380
+ const busy = (S.live?.staff ?? []).some((l) => l.running?.length);
381
+ timer = later(poll, busy ? FAST : SLOW);
382
+ });
383
+ }
384
+
385
+ /* ------------------------------ your requests ---------------------------- */
386
+
387
+ /** A card of rows, laid out like Working now. */
388
+ function listBox(rows) {
389
+ return el("div", { className: "card homeworking" }, rows);
390
+ }
391
+
392
+ /**
393
+ * One row in a list card: a status chip, the title with one line under it, and a button on the
394
+ * right. The whole row opens the thread, except the button.
395
+ */
396
+ function listRow(e, { chip, tone = "", sub, action = null }) {
397
+ const row = el(
398
+ "div",
399
+ { className: "homeline homeclick" + (e.item.unread ? " unread" : ""), tabIndex: 0, role: "button" },
400
+ [
401
+ el("span", { className: "chip homestate " + tone, textContent: chip }),
402
+ el("div", { className: "homerunwhat" }, [
403
+ el("div", { className: "homerowtitle", textContent: e.item.title }),
404
+ el("div", { className: "homerunsub", textContent: `${whereOf(e)} · ${sub}` }),
405
+ ]),
406
+ el("div", { className: "homerunlog" }, action ? [action] : []),
407
+ ],
408
+ );
409
+ row.onclick = (ev) => {
410
+ if (!ev.target.closest("button, a")) openThread(e.item);
411
+ };
412
+ row.onkeydown = (ev) => {
413
+ if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
414
+ ev.preventDefault();
415
+ openThread(e.item);
416
+ }
417
+ };
418
+ return row;
419
+ }
420
+
421
+ const WHY = {
422
+ status: "Status",
423
+ peer: "Peer ask",
424
+ own: "Their work",
425
+ draft: "Draft",
426
+ contributor: "Issue",
427
+ other: "Issue",
428
+ };
429
+
430
+ /** Something on a staff member's tracker, or elsewhere, with activity you have not seen. */
431
+ function unreadRow(e) {
432
+ return listRow(e, {
433
+ chip: WHY[e.why] ?? "Issue",
434
+ tone: "cool",
435
+ sub: ago(e.item.updatedAt),
436
+ });
437
+ }
438
+
439
+ const STATUS = { waiting: "Waiting", working: "Working", answered: "Answered" };
440
+
441
+ function requestRow(e) {
442
+ return listRow(e, {
443
+ chip: STATUS[e.status],
444
+ tone: e.status === "answered" ? "cool" : e.status === "working" ? "ok" : "",
445
+ sub: ago(e.item.updatedAt),
446
+ });
447
+ }
448
+
449
+ function closedRow(e) {
450
+ const note = el("span", { className: "meta" });
451
+ const reopen = button("Reopen", false, () =>
452
+ act(note, holder, { action: "reopen", repo: e.item.repo, number: e.item.number }, "Reopened."),
453
+ );
454
+ const holder = el("div", { className: "row" }, [note, reopen]);
455
+ return listRow(e, {
456
+ chip: "Closed",
457
+ sub: e.reason || "No reason given.",
458
+ action: holder,
459
+ });
460
+ }
461
+
462
+ /* --------------------------------- rest --------------------------------- */
463
+
464
+ function rest(h) {
465
+ const parts = [];
466
+ if (h.staff.length) parts.push(`${h.staff.length} on the staff's own trackers`);
467
+ if (h.elsewhere.length) parts.push(`${h.elsewhere.length} elsewhere`);
468
+ if (!parts.length) return el("span");
469
+ const link = el("button", { className: "ghbtn", textContent: "Open Trackers" });
470
+ link.onclick = () => go({ view: "inbox", inboxStaff: "", inboxOpen: null });
471
+ return el("div", { className: "row homerest" }, [
472
+ el("span", { className: "meta", textContent: "Also open: " + parts.join(", ") + "." }),
473
+ link,
474
+ ]);
475
+ }
476
+
477
+ /* -------------------------------- helpers -------------------------------- */
478
+
479
+ function section(title, count) {
480
+ const h = el("h2", { textContent: title });
481
+ if (count !== undefined) h.append(el("span", { className: "pill", textContent: String(count) }));
482
+ return h;
483
+ }
484
+
485
+ function openLink(item) {
486
+ const b = el("button", { className: "homeopen", textContent: item.title });
487
+ b.onclick = () => openThread(item);
488
+ return b;
489
+ }
490
+
491
+ /** Repaint Home where you were: the sheet may have changed what is on it, not where you are. */
492
+ function repaintInPlace() {
493
+ const main = document.querySelector("#main");
494
+ const at = { win: window.scrollY, main: main?.scrollTop ?? 0 };
495
+ render();
496
+ window.scrollTo(0, at.win);
497
+ if (main) main.scrollTop = at.main;
498
+ }
499
+
500
+ /**
501
+ * The thread in a side sheet: its header, actions, reply box and conversation. Closing it
502
+ * repaints Home, since a reply or a close there changes what belongs where. Where there is no
503
+ * <dialog> (the test shim), it falls back to the thread on Trackers.
504
+ */
505
+ function openThread(item) {
506
+ const ref = { repo: item.repo, number: item.number, kind: item.kind };
507
+ const body = el("div");
508
+ const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
509
+ x.innerHTML = iconHTML("close");
510
+ body.append(el("div", { className: "sidebar-x" }, [x]));
511
+ threadInto(body, ref);
512
+ const box = sheet({ node: body, side: true, onClose: () => S.view === "home" && repaintInPlace() });
513
+ if (!box) {
514
+ go({ view: item.kind === "pr" ? "prs" : "inbox", inboxStaff: "", inboxFilter: "", inboxOpen: ref });
515
+ return;
516
+ }
517
+ x.onclick = () => box.close();
518
+ }
519
+
520
+ function whereOf(e) {
521
+ const repo = e.item.repo.split("/")[1] ?? e.item.repo;
522
+ return (e.staff?.name ?? repo) + " · #" + e.item.number;
523
+ }
524
+
525
+ function button(label, primary, onclick) {
526
+ const b = el("button", { className: "ghbtn" + (primary ? " primary" : ""), textContent: label });
527
+ b.onclick = onclick;
528
+ return b;
529
+ }
530
+
531
+ /** How many runs have finished across the staff, to notice one ending. */
532
+ function finishedCount(live) {
533
+ return (live?.staff ?? []).reduce((n, l) => n + (l.finished?.length ?? 0), 0);
534
+ }
535
+
536
+ /**
537
+ * Re-read the issues and repaint where you were. After a write it also looks for the run the
538
+ * write starts; from the poll it does not, since the poll is already running.
539
+ */
540
+ function refreshSoon(lookForRun = true) {
541
+ issuesAt = Date.now();
542
+ ensureInbox(true)
543
+ .then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
544
+ .catch(() => {});
545
+ if (lookForRun) later(poll, 3_000);
546
+ }