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

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 (67) hide show
  1. package/dist/cli.js +856 -169
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +116 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +105 -5
  42. package/templates/portal/css/layout.css +37 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +12 -2
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +68 -8
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +53 -8
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +934 -98
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/staff.js +62 -2
  65. package/templates/portal/js/yaml.js +134 -0
  66. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  67. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -1,17 +1,72 @@
1
1
  /* The inbox: everything open across the org, and the thread beside it. */
2
2
 
3
- import { getInbox, getThread, post } from "../api.js";
4
- import { ago, el, esc, markCurrent } from "../dom.js";
3
+ import { getLabels, getPr, getThread, post, upload } from "../api.js";
4
+ import { askText } from "../dialog.js";
5
+ import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
5
6
  import { icon, iconHTML } from "../icons.js";
6
7
  import { mdlite } from "../md.js";
7
- import { refreshAll } from "../refresh.js";
8
- import { S, openCount, writeHash } from "../state.js";
8
+ import { attachMentions } from "../mention.js";
9
+ import { ensureInbox, refreshAll, stampCounts } from "../refresh.js";
10
+ import { humanLabel, humansOf, isHuman, S, writeHash } from "../state.js";
11
+ import { renderDiff } from "./changed.js";
9
12
 
13
+ /* The labels roster itself creates, and what each one means about urgency. Everything else is
14
+ the org's own vocabulary and stays plain: colouring a word this file has never heard of
15
+ would be guessing. */
10
16
  const LABEL_TONE = {
11
- decision: "hot", blocked: "hot", will: "hot", review: "warm", submit: "warm",
17
+ decision: "hot", blocked: "hot", review: "warm", submit: "warm",
12
18
  idea: "cool", build: "cool", setup: "cool", data: "cool",
13
19
  };
14
20
 
21
+ /**
22
+ * A label's tone.
23
+ *
24
+ * `roster hire` puts a label on every tracker named after the human's marker, so "this is on a
25
+ * person" is a label like `will` or `sam` — it used to be spelled `will` in the map above,
26
+ * which coloured exactly one org's and left every other org's looking like an ordinary tag.
27
+ * It comes off org.yaml now, so it is right wherever this runs.
28
+ */
29
+ function tone(label) {
30
+ if (LABEL_TONE[label]) return LABEL_TONE[label];
31
+ const name = String(label).toLowerCase();
32
+ return humansOf().some(
33
+ (h) => String(h.marker ?? "").toLowerCase() === name ||
34
+ String(h.github ?? "").toLowerCase() === name,
35
+ )
36
+ ? "hot"
37
+ : "";
38
+ }
39
+
40
+ /**
41
+ * The staff member this repo is the tracker for, or null.
42
+ *
43
+ * A staff member's caller workflow lives in their own brain repo and gates on their `@handle`
44
+ * appearing there. So a mention is a mechanism in exactly one repository per person, and
45
+ * decoration in every other — including the product repos, where the pull requests are.
46
+ *
47
+ * That asymmetry is invisible on the page otherwise: the reply box takes `@cto` anywhere and
48
+ * posts it anywhere, and the difference between "asked the CTO" and "typed their name" is a
49
+ * workflow file in another repository. This is what the two things below use to say so.
50
+ */
51
+ function listener(repo) {
52
+ return S.data.staff.find((s) => s.brain && s.brain === repo) ?? null;
53
+ }
54
+
55
+ /** The handles mentioned in some text, as staff members. */
56
+ function mentioned(text) {
57
+ const said = String(text ?? "");
58
+ return S.data.staff.filter((s) => {
59
+ const at = String(s.mention ?? "@" + s.handle);
60
+ return new RegExp("(^|\\s)" + at.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(\\s|$)", "i").test(said);
61
+ });
62
+ }
63
+
64
+ /** Half-written replies, by thread, for as long as the page is open. */
65
+ const DRAFTS = new Map();
66
+
67
+ /** A pull request's commits and diff, once fetched. Same lifetime, same reason. */
68
+ const PRS = new Map();
69
+
15
70
  /* Bookkeeping. Real history, worth being able to see, but not what you opened the thread
16
71
  to read — so a run of it folds away. */
17
72
  const QUIET = new Set([
@@ -37,9 +92,29 @@ export function belongsTo(item, s) {
37
92
  return false;
38
93
  }
39
94
 
40
- export function viewInbox(m) {
41
- m.append(el("h1", { textContent: "Inbox" }));
42
- const sub = el("p", { className: "sub", textContent: "Everything open across the org." });
95
+ export const viewInbox = (m) => inboxScreen(m, {});
96
+
97
+ /**
98
+ * The same screen, scoped to pull requests.
99
+ *
100
+ * Its own place in the sidebar because open PRs are a different question from an inbox: an
101
+ * inbox is what is waiting on you, and a PR is work that is finished and waiting on a merge.
102
+ * Everything else — the threads, the reactions, the commits and the diff — is the one
103
+ * implementation, because a PR is a thread with more on it and not a second kind of screen.
104
+ */
105
+ export const viewPrs = (m) => inboxScreen(m, { prs: true });
106
+
107
+ function inboxScreen(m, opts) {
108
+ /* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
109
+ has finished and cannot land on their own, which is the thing you are being asked about.
110
+ That it arrives as a pull request is how it is delivered, not what it is. */
111
+ m.append(el("h1", { textContent: opts.prs ? "Pending work" : "Inbox" }));
112
+ const sub = el("p", {
113
+ className: "sub",
114
+ textContent: opts.prs
115
+ ? "Work the staff have finished and cannot land themselves."
116
+ : "Everything open across the org. Pull requests are in Pending work.",
117
+ });
43
118
  m.append(sub);
44
119
 
45
120
  const search = el("input", {
@@ -50,11 +125,11 @@ export function viewInbox(m) {
50
125
  const scope = el("select");
51
126
  scope.append(
52
127
  el("option", { value: "", textContent: "Everything" }),
53
- el("option", { value: "mine", textContent: "On " + (S.data.human.name ?? "me") }),
54
- el("option", { value: "decision", textContent: "Decisions" }),
55
- el("option", { value: "pr", textContent: "Open work (PRs)" }),
128
+ el("option", { value: "mine", textContent: "On " + humanLabel() }),
129
+ // A decision is not a PR, so this one is only ever worth offering on the inbox.
130
+ ...(opts.prs ? [] : [el("option", { value: "decision", textContent: "Decisions" })]),
56
131
  );
57
- scope.value = S.inboxFilter;
132
+ scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
58
133
 
59
134
  /* Open only by default. An inbox is what is waiting on somebody, and burying that under
60
135
  five months of finished work would be answering a different question. */
@@ -77,13 +152,17 @@ export function viewInbox(m) {
77
152
 
78
153
  const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
79
154
  refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
80
- const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
81
- newBtn.onclick = () => newIssueForm(viewer);
82
- m.append(
83
- el("div", { className: "row", style: "margin-bottom:16px" }, [
84
- search, whose, state, scope, refresh, newBtn,
85
- ]),
86
- );
155
+ const controls = [search, whose, state, scope, refresh];
156
+ // A pull request comes from a branch, so there is nothing here that could open one.
157
+ if (!opts.prs) {
158
+ const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
159
+ newBtn.onclick = () => {
160
+ delete viewer.dataset.wait;
161
+ newIssueForm(viewer);
162
+ };
163
+ controls.push(newBtn);
164
+ }
165
+ m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
87
166
 
88
167
  const split = el("div", { className: "split" });
89
168
  const list = el("div", { className: "tree" });
@@ -97,45 +176,82 @@ export function viewInbox(m) {
97
176
  state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
98
177
  refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
99
178
 
100
- if (S.inbox) { stampCount(); paint(); restore(); } else load(false);
179
+ if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
101
180
 
102
181
  /** A `?t=` in the URL names a thread. It used to be read into the state and then never
103
182
  acted on, because only the already-loaded branch opened one. */
104
183
  function restore() {
105
- if (S.inboxOpen) openThread(S.inboxOpen);
184
+ if (!S.inboxOpen) return;
185
+ // The two screens share one open thread, and each holds half the org. Whatever was open on
186
+ // the other one is not in this list, so it is dropped rather than opened beside it.
187
+ if (!!opts.prs !== (S.inboxOpen.kind === "pr")) {
188
+ S.inboxOpen = null;
189
+ writeHash(false);
190
+ return;
191
+ }
192
+ openThread(S.inboxOpen);
106
193
  }
107
194
 
195
+ /**
196
+ * Fetch, through the one request the shell may already have in flight.
197
+ *
198
+ * Waiting looks like what is coming: rows in the list, a thread in the viewer. The reads run
199
+ * across every repo in the org, so on a cold cache this is seconds, and seconds of a blank
200
+ * panel beside a blank panel reads as a page that has failed rather than one that is working.
201
+ */
108
202
  async function load(force) {
109
- list.replaceChildren(el("p", { className: "empty", textContent: "Asking GitHub…" }));
203
+ sub.dataset.busy = "1";
204
+ list.replaceChildren(
205
+ el("p", { className: "skhead", textContent: "Asking GitHub…" }),
206
+ ...skeleton("row", 7),
207
+ );
208
+ /* The flag is what makes the placeholder safe to remove: you can open New issue while this
209
+ is still running, and the arriving inbox must not throw away what you have started
210
+ typing. Anything that writes real content into the viewer clears it. */
211
+ if (!S.inboxOpen) {
212
+ viewer.dataset.wait = "1";
213
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 6));
214
+ }
110
215
  try {
111
- S.inbox = await getInbox(force);
216
+ await ensureInbox(force);
112
217
  } catch (e) {
113
218
  refresh.classList.remove("spin");
219
+ delete sub.dataset.busy;
220
+ clearPlaceholder();
114
221
  list.replaceChildren(
115
222
  el("p", { className: "empty err", textContent: "Could not reach GitHub: " + e.message }),
116
223
  );
117
224
  return;
118
225
  }
119
226
  refresh.classList.remove("spin");
120
- stampCount();
227
+ delete sub.dataset.busy;
228
+ stampCounts();
121
229
  paint();
122
230
  restore();
231
+ // Nothing was selected, so the placeholder thread has nothing to become.
232
+ clearPlaceholder();
123
233
  }
124
234
 
125
- /* The sidebar badge is painted by the shell, which runs before this screen has asked
126
- GitHub anything. Without this it stays empty until something else causes a render. */
127
- function stampCount() {
128
- const badge = document.querySelector("#inboxcount");
129
- if (badge) badge.textContent = S.inbox ? String(openCount()) : "";
235
+ /** Take the waiting shapes away, and leave alone anything that replaced them. */
236
+ function clearPlaceholder() {
237
+ if (viewer.dataset.wait !== "1") return;
238
+ delete viewer.dataset.wait;
239
+ viewer.replaceChildren();
130
240
  }
131
241
 
132
242
  function paint() {
133
243
  if (!S.inbox) return;
134
244
  const q = S.query.trim().toLowerCase();
135
- const human = S.data.human.github;
245
+ // "On you" is on any of the humans this org answers to, not only the first one.
246
+ const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
136
247
 
137
248
  const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
138
- const mine = S.inbox.items.filter((i) => belongsTo(i, whoseStaff));
249
+ /* The two screens partition the org rather than overlap on it. Pending work is every pull
250
+ request; the inbox is everything else. An inbox that also listed the PRs said the same
251
+ thing twice and made the badge beside it a number you could not act on. */
252
+ const mine = S.inbox.items
253
+ .filter((i) => belongsTo(i, whoseStaff))
254
+ .filter((i) => (i.kind === "pr") === !!opts.prs);
139
255
  const isOpen = (i) => i.state === "OPEN";
140
256
  const scoped = mine.filter(
141
257
  S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
@@ -145,23 +261,36 @@ export function viewInbox(m) {
145
261
  (i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
146
262
  .toLowerCase().includes(q),
147
263
  );
148
- if (S.inboxFilter === "mine") items = items.filter((i) => i.assignees.includes(human));
264
+ if (S.inboxFilter === "mine") items = items.filter(onAHuman);
149
265
  else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
150
- else if (S.inboxFilter === "pr") items = items.filter((i) => i.kind === "pr");
266
+ /* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
267
+ old bookmark carrying it is ignored rather than honoured into an empty list. */
151
268
 
152
269
  const open = mine.filter(isOpen);
153
- const onYou = open.filter((i) => i.assignees.includes(human)).length;
154
- const prs = open.filter((i) => i.kind === "pr").length;
270
+ const onYou = open.filter(onAHuman).length;
155
271
  const shut = mine.length - open.length;
272
+ /* Pending work owns the pull requests now, so this is a pointer to that screen rather than
273
+ a count of anything on this list. Scoped the same way, or the two would not add up. */
274
+ const prs = S.inbox.items.filter(
275
+ (i) => i.kind === "pr" && isOpen(i) && belongsTo(i, whoseStaff),
276
+ ).length;
156
277
  const where = whoseStaff
157
278
  ? " for <b>" + esc(whoseStaff.name) + "</b>"
158
279
  : " across " + (S.inbox.repos ?? []).length + " repos";
280
+ /* On the PR screen the count that matters is not "how many are open" — it is how many are
281
+ green and still sitting there, because that is the pile you are the bottleneck on. */
282
+ const failing = open.filter((i) => i.checks === "failing").length;
283
+ const ready = open.filter((i) => i.checks === "passing").length;
159
284
  sub.innerHTML =
160
285
  (S.inboxState === "closed"
161
286
  ? shut + " closed in the last 45 days" + where
162
- : open.length + " open" + where + " · <b>" + onYou + " on " +
163
- esc(S.data.human.name ?? "you") + "</b> · " + prs + " open PRs" +
164
- (S.inboxState === "all" ? " · " + shut + " closed" : "")) +
287
+ : opts.prs
288
+ ? open.length + " open" + where + " · <b>" + ready + " with checks passing</b>" +
289
+ (failing ? " · " + failing + " failing" : "") +
290
+ (S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
291
+ : open.length + " open" + where + " · <b>" + onYou + " on " +
292
+ esc(humanLabel()) + "</b> · " + prs + " in Pending work" +
293
+ (S.inboxState === "all" ? " · " + shut + " closed" : "")) +
165
294
  (S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
166
295
 
167
296
  list.replaceChildren();
@@ -180,13 +309,14 @@ export function viewInbox(m) {
180
309
  for (const i of items) {
181
310
  const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
182
311
  const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
183
- const yours = i.assignees.includes(human);
312
+ const yours = onAHuman(i);
184
313
  const chips = i.labels
185
314
  .slice(0, 3)
186
- .map((l) => '<span class="chip ' + (LABEL_TONE[l] ?? "") + '">' + esc(l) + "</span>")
315
+ .map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>")
187
316
  .join("");
188
317
  b.innerHTML =
189
- '<div class="ititle">' + (yours ? '<span class="dot" title="assigned to you"></span>' : "") +
318
+ '<div class="ititle">' +
319
+ (yours ? '<span class="dot" title="assigned to ' + esc(humanLabel()) + '"></span>' : "") +
190
320
  esc(i.title) + "</div>" +
191
321
  '<div class="imeta">' +
192
322
  '<span class="repo">' + esc(i.repo.split("/")[1]) + "</span>" +
@@ -197,6 +327,18 @@ export function viewInbox(m) {
197
327
  '<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
198
328
  (i.checks && i.checks !== "none"
199
329
  ? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
330
+ /* Green checks on a branch that no longer applies is the one case where the row
331
+ reads as ready and is not. It has to say so here, not three clicks in. */
332
+ (i.mergeable === "CONFLICTING"
333
+ ? '<span class="chip conflict" title="This branch conflicts with its base">' +
334
+ "conflicts</span>"
335
+ : "") +
336
+ // How much conversation is on a thread, which is most of what tells a live one from
337
+ // something that was filed and never answered.
338
+ (i.comments.length
339
+ ? '<span class="cc" title="' + i.comments.length + ' comments">' +
340
+ iconHTML("review") + i.comments.length + "</span>"
341
+ : "") +
200
342
  chips +
201
343
  '<span class="when">' + ago(i.updatedAt) + "</span>" +
202
344
  "</div>";
@@ -228,80 +370,353 @@ export function viewInbox(m) {
228
370
 
229
371
  const head = el("div", { className: "thead" });
230
372
  head.innerHTML =
231
- '<div class="trow">' +
232
- '<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
233
- ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
234
- " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
235
- '<a class="ghbtn" href="' + esc(item.url) + '" target="_blank" rel="noopener">Open in GitHub</a>' +
236
- "</div>" +
373
+ '<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
374
+ ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
375
+ " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
237
376
  "<h3>" + esc(item.title) + "</h3>" +
377
+ (item.mergeable === "CONFLICTING"
378
+ ? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
379
+ "until someone rebases or merges the base into it.</p>"
380
+ : "") +
238
381
  (item.labels.length
239
- ? '<div class="row" style="margin-top:8px">' +
240
- item.labels.map((l) => '<span class="chip ' + (LABEL_TONE[l] ?? "") + '">' + esc(l) + "</span>").join("") +
382
+ ? '<div class="row tlabels">' +
383
+ item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
241
384
  "</div>"
242
385
  : "");
386
+ head.append(threadActions(item));
387
+ delete viewer.dataset.wait;
243
388
  viewer.replaceChildren(head);
244
389
 
245
- viewer.append(comment(item.author, item.createdAt, item.body, true, item.repo));
246
- for (const node of timeline(item, openThread)) viewer.append(node);
247
- viewer.append(replyBox(item));
390
+ /* A pull request is a conversation, a set of commits and a diff. The inbox carries the
391
+ first; the other two are a click away rather than in every refresh of every repo.
392
+ An issue has only the conversation, so it stays flat in the viewer rather than paying
393
+ for a wrapper that would only ever hold one thing. */
394
+ const pane = item.kind === "pr" ? el("div") : viewer;
395
+ if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
396
+ conversation(pane, item);
248
397
  viewer.scrollTop = 0;
249
398
  }
250
399
 
251
- /* Replies and closes go out as the human, through their own gh. This is where a person
252
- answers their agents, so it should be the same as typing it on the site. */
253
- function replyBox(item) {
254
- const box = el("div", { className: "reply" });
255
- const ta = el("textarea", {
256
- placeholder: "Reply as " + (S.data.human.github ?? "you") + "…",
257
- rows: 3,
258
- });
259
- const status = el("span", { className: "meta" });
400
+ /** Appends; whoever is swapping panes owns clearing them. */
401
+ function conversation(pane, item) {
402
+ /* Newest first, opening post last. GitHub's own order puts the answer you came for at the
403
+ bottom of a year of bookkeeping, and the thing you do most on this screen is read what
404
+ just happened. The title and the actions are in the header, so nothing you need is
405
+ further down than the first screenful. */
406
+ const from = pane.children.length;
407
+ for (const node of timeline(item, openThread)) pane.append(node);
408
+ pane.append(
409
+ comment({
410
+ author: item.author, when: item.createdAt, body: item.body, first: true,
411
+ repo: item.repo, reactions: item.reactions,
412
+ }),
413
+ );
414
+ /* Whatever ends up first drops its own top rule. The header already draws a line under
415
+ itself, and with the thread reversed the first thing under it is any of three shapes. */
416
+ pane.children[from]?.classList?.add("lead");
417
+ }
260
418
 
261
- const send = el("button", { className: "ghbtn primary", textContent: "Comment" });
262
- const closeBtn = el("button", {
263
- className: "ghbtn",
264
- textContent: item.state === "OPEN" ? "Close" : "Reopen",
419
+ /**
420
+ * Conversation, commits, files — for pull requests only.
421
+ *
422
+ * The detail is fetched once per thread and kept, so switching back and forth is free. A
423
+ * failure is shown in the pane rather than swallowed: "no diff" and "GitHub would not give
424
+ * me the diff" are different answers.
425
+ */
426
+ function prTabs(item, pane) {
427
+ const bar = el("div", { className: "tabs" });
428
+ const key = item.repo + "#" + item.number;
429
+ let detail = PRS.get(key) ?? null;
430
+
431
+ const tabs = [
432
+ ["conversation", "Conversation", () => { pane.replaceChildren(); conversation(pane, item); }],
433
+ ["commits", "Commits", () => paintCommits(pane, detail)],
434
+ ["files", "Files", () => paintFiles(pane, detail, item)],
435
+ ];
436
+
437
+ const pick = async (id) => {
438
+ for (const b of bar.children) b.setAttribute("aria-current", String(b.dataset.tab === id));
439
+ if (id === "conversation") {
440
+ pane.replaceChildren();
441
+ conversation(pane, item);
442
+ return;
443
+ }
444
+ if (!detail) {
445
+ pane.replaceChildren(el("p", { className: "empty", textContent: "Asking GitHub…" }));
446
+ try {
447
+ detail = await getPr(item.repo, item.number);
448
+ PRS.set(key, detail);
449
+ } catch (e) {
450
+ pane.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
451
+ return;
452
+ }
453
+ }
454
+ if (detail.error) {
455
+ pane.replaceChildren(el("p", { className: "empty err", textContent: detail.error }));
456
+ return;
457
+ }
458
+ tabs.find((t) => t[0] === id)[2]();
459
+ };
460
+
461
+ for (const [id, label] of tabs) {
462
+ const b = el("button", { className: "tab", textContent: label });
463
+ b.dataset.tab = id;
464
+ b.setAttribute("aria-current", String(id === "conversation"));
465
+ b.onclick = () => pick(id);
466
+ bar.append(b);
467
+ }
468
+ return bar;
469
+ }
470
+
471
+ function paintCommits(pane, d) {
472
+ pane.replaceChildren(
473
+ el("p", { className: "meta", style: "margin:14px 0 10px",
474
+ textContent: d.commits.length + " commits · " + d.head + " → " + d.base }),
475
+ );
476
+ if (!d.commits.length) {
477
+ pane.append(el("p", { className: "empty", textContent: "No commits on this branch yet." }));
478
+ return;
479
+ }
480
+ for (const c of d.commits) {
481
+ const row = el("div", { className: "prcommit" });
482
+ row.append(
483
+ el("a", { className: "sha", href: c.url, target: "_blank", rel: "noopener", textContent: c.sha }),
484
+ el("span", { className: "subj", textContent: c.subject }),
485
+ el("span", { className: "meta", textContent: c.author + (c.date ? " · " + ago(c.date) : "") }),
486
+ );
487
+ pane.append(row);
488
+ }
489
+ }
490
+
491
+ function paintFiles(pane, d, item) {
492
+ pane.replaceChildren(
493
+ el("p", { className: "meta", style: "margin:14px 0 10px",
494
+ textContent: d.changedFiles + " files · +" + d.additions + " −" + d.deletions }),
495
+ );
496
+ if (!d.files.length) {
497
+ pane.append(el("p", { className: "empty", textContent: "This pull request changes nothing." }));
498
+ return;
499
+ }
500
+ for (const f of d.files) {
501
+ /* renderDiff reads a git diff, so each file's patch is given the header the API leaves
502
+ off. A file with no patch is binary or too big for GitHub to send one — say which. */
503
+ if (!f.patch) {
504
+ const wrap = el("div", { className: "dblock" });
505
+ const h = el("div", { className: "dfile" });
506
+ h.innerHTML = "<b>" + esc(f.path) + "</b>" +
507
+ '<span class="p">+' + f.additions + '</span><span class="m">−' + f.deletions + "</span>";
508
+ wrap.append(h, el("p", { className: "dempty",
509
+ textContent: "No patch: this file is binary or too large for the API to send one." }));
510
+ pane.append(wrap);
511
+ continue;
512
+ }
513
+ const blocks = renderDiff("diff --git a/" + f.path + " b/" + f.path + "\n" + f.patch);
514
+ /* The ask goes in the file's own heading, because "this file is wrong" is the thing you
515
+ want to say while looking at one file, and the alternative is describing in prose which
516
+ of thirty files you meant. The hunk goes with it, so the agent gets the part you were
517
+ reading rather than the whole diff and a guess. */
518
+ const head = blocks[0]?.children?.[0];
519
+ if (item && head && String(head.className ?? "").includes("dfile")) {
520
+ head.append(replyAboutFile(item, f));
521
+ }
522
+ for (const block of blocks) pane.append(block);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * The same reply box, opened about one file.
528
+ *
529
+ * "This bit is wrong" is what you want to say while looking at one file, and the alternative
530
+ * is describing in prose which of thirty files you meant. It is not a second kind of thing:
531
+ * it opens the composer the Reply button opens, with the file remembered, so the hunk rides
532
+ * along to whoever you name in it.
533
+ */
534
+ function replyAboutFile(item, f) {
535
+ const b = el("button", {
536
+ className: "ghbtn askfile",
537
+ textContent: "Reply",
538
+ title: "Reply about " + f.path,
265
539
  });
540
+ b.onclick = async () => {
541
+ b.disabled = true;
542
+ try {
543
+ // Same reason as the header's Reply: the wait is after the box closes, not during it.
544
+ const r = await openReply(item, f, () => {
545
+ b.textContent = "Replying…";
546
+ });
547
+ b.textContent = r ? (r.warning ? "sent, with a warning" : "sent") : "Reply";
548
+ } catch (e) {
549
+ b.textContent = e.message;
550
+ }
551
+ b.disabled = false;
552
+ };
553
+ return b;
554
+ }
555
+
556
+ /**
557
+ * Reply, close, merge — at the top of the thread, next to the title.
558
+ *
559
+ * They used to sit under the last comment, which meant scrolling a long thread to the end to
560
+ * answer it, and then the thread put the newest thing there too. The actions are about the
561
+ * thread rather than about its last message, so they belong where the thread starts. Writing
562
+ * happens in a dialog for the same reason: a box you can only reach by scrolling is a box you
563
+ * stop using.
564
+ *
565
+ * All of it goes out as the human, through their own `gh`, so it is indistinguishable from
566
+ * doing it on the site.
567
+ */
568
+ function threadActions(item) {
569
+ const key = item.repo + "#" + item.number;
570
+ const status = el("span", { className: "meta" });
571
+ const buttons = [];
266
572
 
267
573
  const busy = (on, msg) => {
268
- send.disabled = closeBtn.disabled = on;
574
+ for (const b of buttons) b.disabled = on;
269
575
  status.textContent = msg ?? "";
270
576
  status.className = "meta";
271
577
  };
272
578
  const failed = (e) => {
273
579
  status.textContent = e.message;
274
580
  status.className = "meta err";
581
+ busy(false);
582
+ status.className = "meta err";
583
+ };
584
+
585
+ /* A reply you started and did not send survives closing the dialog, and comes back the
586
+ next time you open it rather than being lost to a stray Escape. `compose` is kept for
587
+ the close button below, which also takes a parting comment. */
588
+ const compose = ({ title, hint, confirm, allowEmpty }) =>
589
+ askText({
590
+ title,
591
+ hint,
592
+ confirm,
593
+ allowEmpty,
594
+ value: DRAFTS.get(key) ?? "",
595
+ placeholder:
596
+ "Reply as " + (humansOf().length === 1 ? humansOf()[0].github : "yourself") + "…",
597
+ decorate: (ta) => {
598
+ ta.oninput = () => {
599
+ if (ta.value) DRAFTS.set(key, ta.value);
600
+ else DRAFTS.delete(key);
601
+ };
602
+ return attachBox(ta, () => item.repo).node;
603
+ },
604
+ });
605
+
606
+ /* One button, because there was no way to tell the two apart. Reply reaches whoever you
607
+ named in it; see `openReply`. */
608
+ const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
609
+
610
+ /* Posting a comment is a round trip to GitHub through `gh`, and on a cold connection that
611
+ is seconds of a button that still says "Reply" and still takes clicks. Saying nothing
612
+ reads as a page that ignored you, which is how the same comment gets sent twice. */
613
+ const sending = (on) => {
614
+ busy(on, on ? "sending…" : "");
615
+ reply.textContent = on ? "Replying…" : "Reply";
275
616
  };
276
617
 
277
- send.onclick = async () => {
278
- if (!ta.value.trim()) { ta.focus(); return; }
279
- busy(true, "posting…");
618
+ reply.onclick = async () => {
619
+ let r;
280
620
  try {
281
- await post({ action: "comment", repo: item.repo, number: item.number, body: ta.value });
282
- ta.value = "";
283
- await reloadThread(item);
284
- } catch (e) { failed(e); busy(false); }
621
+ r = await openReply(item, null, () => sending(true));
622
+ } catch (e) {
623
+ sending(false);
624
+ failed(e);
625
+ return;
626
+ }
627
+ // Closed the box without sending: nothing ever started, so there is nothing to put back.
628
+ if (!r) {
629
+ sending(false);
630
+ return;
631
+ }
632
+ /* Repaint only when something was said on this thread, which is always true here: `ask`
633
+ posts the comment too. A warning means the tracker issue went up and the comment did
634
+ not, and then the status line is the only thing that says so. */
635
+ if (r.warning) {
636
+ sending(false);
637
+ status.className = "meta err";
638
+ status.textContent = r.warning;
639
+ return;
640
+ }
641
+ /* Held across the reload as well as the post. The reply is not on screen until the
642
+ thread repaints, and a button that said "Reply" again before then would be claiming
643
+ it was done over a thread that still did not show it. */
644
+ await reloadThread(item);
645
+ sending(false);
285
646
  };
647
+ buttons.push(reply);
286
648
 
649
+ const closeBtn = el("button", {
650
+ className: "ghbtn",
651
+ textContent: item.state === "OPEN" ? "Close" : "Reopen",
652
+ });
287
653
  closeBtn.onclick = async () => {
288
654
  const closing = item.state === "OPEN";
289
- // Closing is the one thing here that is awkward to undo from a phone later.
290
- if (closing && !confirm("Close " + item.repo + " #" + item.number + "?")) return;
655
+ let body;
656
+ if (closing) {
657
+ // Closing is the one thing here that is awkward to undo from a phone later, so it asks
658
+ // — and since it is asking anyway, it takes a parting comment.
659
+ body = await compose({
660
+ title: "Close " + item.repo + " #" + item.number + "?",
661
+ hint: "Anything you write here is posted as a comment first. Leave it empty to just close.",
662
+ confirm: "Close it",
663
+ allowEmpty: true,
664
+ });
665
+ if (body === null) return;
666
+ }
291
667
  busy(true, closing ? "closing…" : "reopening…");
292
668
  try {
293
669
  await post({
294
670
  action: closing ? "close" : "reopen",
295
671
  repo: item.repo,
296
672
  number: item.number,
297
- body: closing ? ta.value : undefined,
673
+ body: body || undefined,
298
674
  });
675
+ DRAFTS.delete(key);
299
676
  await refreshAll(false);
300
- } catch (e) { failed(e); busy(false); }
677
+ } catch (e) { failed(e); }
301
678
  };
679
+ buttons.push(closeBtn);
680
+
681
+ const row = el("div", { className: "row tacts" }, buttons);
302
682
 
303
- box.append(ta, el("div", { className: "row", style: "margin-top:9px" }, [send, closeBtn, status]));
304
- return box;
683
+ /* Merging cannot be undone with another click, so it asks first and names what it is about
684
+ to merge. It does not ask *how*: squash, merge commit or rebase is a question about git
685
+ rather than about this pull request, and the repository has already answered it in its
686
+ own settings. The server reads it from there. */
687
+ if (item.kind === "pr" && item.state === "OPEN") {
688
+ const merge = el("button", { className: "ghbtn", textContent: "Merge" });
689
+ /* A conflicted branch cannot be merged by anyone, so the button says why instead of
690
+ spending a round trip to come back with GitHub's version of the same sentence. */
691
+ if (item.mergeable === "CONFLICTING") {
692
+ merge.disabled = true;
693
+ merge.title = "This branch conflicts with its base";
694
+ }
695
+ merge.onclick = async () => {
696
+ if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
697
+ busy(true, "merging…");
698
+ try {
699
+ await post({ action: "merge", repo: item.repo, number: item.number });
700
+ await refreshAll(false);
701
+ } catch (e) { failed(e); }
702
+ };
703
+ buttons.push(merge);
704
+ row.append(merge);
705
+ }
706
+
707
+ row.append(status);
708
+ // Last, and pushed to the far end: it is the way out of this screen rather than one of the
709
+ // things you do on it. Same shape as the rest, so the row reads as one set of controls.
710
+ row.append(
711
+ el("a", {
712
+ className: "ghbtn out",
713
+ href: item.url,
714
+ target: "_blank",
715
+ rel: "noopener",
716
+ textContent: "Open in GitHub",
717
+ }),
718
+ );
719
+ return row;
305
720
  }
306
721
 
307
722
  async function reloadThread(item) {
@@ -318,18 +733,155 @@ export function viewInbox(m) {
318
733
  /* the comment posted; a stale pane is not worth an error */
319
734
  }
320
735
  paint();
736
+ // The thread is newest first, so what you just wrote is the first thing on it.
321
737
  openThread({ repo: item.repo, number: item.number, kind: item.kind });
322
738
  }
323
739
  }
324
740
 
741
+ /* -------------------------- one composer, one box ------------------------- */
742
+
743
+ /**
744
+ * Reply to a thread, and reach whoever you named while doing it.
745
+ *
746
+ * There used to be two buttons on a thread, Reply and "Ask a staff member", and nothing on the
747
+ * page said which one you wanted. They did almost the same thing: both put your words on the
748
+ * thread, and both could open a request on somebody's tracker. The only real difference was
749
+ * that one of them made you pick a name from a dropdown, which is a worse way of saying who you
750
+ * mean than typing their name in the sentence.
751
+ *
752
+ * So there is one box. Type `@cto` in it, and if a comment here would not reach them the offer
753
+ * to open it on their tracker appears underneath, ticked. One press does both.
754
+ *
755
+ * @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
756
+ * @returns the result of the write, or null if nothing was written.
757
+ */
758
+ /**
759
+ * @param onSend Called once, the moment the box is submitted and the writing starts.
760
+ *
761
+ * The dialog and the send are one `await` to whoever called this, so a caller cannot tell the
762
+ * two apart by itself — and the two want opposite things from a button. While the box is open
763
+ * you are still typing and the button has to stay as it was; once it closes there is a request
764
+ * in flight against GitHub that can take seconds, and that is what needs saying.
765
+ */
766
+ async function openReply(item, anchor, onSend) {
767
+ const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
768
+ let deaf = null;
769
+
770
+ const body = await askText({
771
+ title: "Reply to " + item.repo + " #" + item.number,
772
+ hint: anchor ? anchor.path : item.title,
773
+ confirm: "Comment",
774
+ value: DRAFTS.get(key) ?? "",
775
+ /* It goes out through whoever's `gh` is signed in here, which is only knowably one person
776
+ when the org has one. With two it would be a guess, and a wrong name on a reply box is
777
+ worse than no name. */
778
+ placeholder: "Reply as " + (humansOf().length === 1 ? humansOf()[0].github : "yourself") + "…",
779
+ decorate: (ta) => {
780
+ ta.oninput = () => {
781
+ if (ta.value) DRAFTS.set(key, ta.value);
782
+ else DRAFTS.delete(key);
783
+ };
784
+ deaf = deafNote(ta, item, anchor);
785
+ return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
786
+ },
787
+ });
788
+ if (!body) return null;
789
+ onSend?.();
790
+
791
+ /* One press, both writes. `ask` posts the comment itself when told to, so these are not two
792
+ requests racing: the tracker issue goes up first because it is the half that actually
793
+ reaches anybody, and the comment on the thread carries a link to it.
794
+
795
+ Who to reach comes off the text that was submitted rather than off what was in the box
796
+ while it was being typed, so a handle you typed and then deleted is not asked. */
797
+ const reaching = listener(item.repo) ? [] : mentioned(body);
798
+ const to = deaf?.declined() ? null : reaching[0];
799
+ if (!to) {
800
+ DRAFTS.delete(key);
801
+ return post({ action: "comment", repo: item.repo, number: item.number, body });
802
+ }
803
+
804
+ const detail = PRS.get(item.repo + "#" + item.number);
805
+ const r = await post({
806
+ action: "ask",
807
+ repo: to.brain,
808
+ ask: {
809
+ staff: {
810
+ handle: to.handle,
811
+ name: to.name,
812
+ mention: to.mention ?? "@" + to.handle,
813
+ brain: to.brain,
814
+ },
815
+ pr: {
816
+ repo: item.repo,
817
+ number: item.number,
818
+ title: item.title ?? "",
819
+ url: item.url ?? "",
820
+ kind: item.kind,
821
+ // The branch names only. The rest of the detail is commits and every file's patch, and
822
+ // none of it belongs in a request that is about a sentence.
823
+ head: detail?.head,
824
+ base: detail?.base,
825
+ },
826
+ body,
827
+ ...(anchor ? { anchor: { path: anchor.path, patch: anchor.patch } } : {}),
828
+ },
829
+ alsoOnPr: true,
830
+ });
831
+ DRAFTS.delete(key);
832
+ return r;
833
+ }
834
+
835
+ /**
836
+ * "You typed a name nobody here answers to." And then an offer to fix it.
837
+ *
838
+ * The `@` list offers every staff member in every box, and posting one on a product repo looks
839
+ * exactly like posting one on a tracker: it goes up, it renders as a chip, and nothing happens.
840
+ *
841
+ * Saying so is not enough. An earlier version told you to post the comment and then go and
842
+ * press the other button, which is the round trip this whole lane exists to remove, with a
843
+ * lecture in front of it. If the box knows who you meant and knows a comment here will not
844
+ * reach them, it should offer to reach them.
845
+ */
846
+ function deafNote(ta, on, anchor) {
847
+ const line = el("span", {});
848
+ const box = el("input", { type: "checkbox", checked: true, id: "reply-alsoask" });
849
+ const offer = el("label", { className: "meta", htmlFor: "reply-alsoask" }, [
850
+ box,
851
+ el("span", {
852
+ textContent:
853
+ " open it on their tracker too, with a link to this" +
854
+ (anchor ? " and the diff for " + anchor.path : ""),
855
+ }),
856
+ ]);
857
+ const note = el("div", { className: "meta err", style: "margin:8px 0 0" }, [line, offer]);
858
+
859
+ const check = () => {
860
+ const deaf = listener(on.repo) ? [] : mentioned(ta.value);
861
+ note.hidden = !deaf.length;
862
+ line.textContent = deaf.length
863
+ ? deaf.map((s) => s.mention ?? "@" + s.handle).join(" and ") +
864
+ " is not woken by a comment on " + on.repo + ": it is not their tracker. "
865
+ : "";
866
+ };
867
+ ta.addEventListener("input", check);
868
+ check();
869
+ /* Only whether the offer was declined. Who, and whether there is anybody at all, is worked
870
+ out from the text that was actually submitted. */
871
+ return { node: note, declined: () => !box.checked };
872
+ }
873
+
325
874
  /* ------------------------------- the thread ------------------------------ */
326
875
 
327
876
  /**
328
- * A thread's history, in GitHub's order: comments and reviews as cards, references as lines,
329
- * and a run of bookkeeping folded behind one disclosure.
877
+ * A thread's history, newest first: comments and reviews as cards, references as lines, and a
878
+ * run of bookkeeping folded behind one disclosure.
330
879
  *
331
880
  * A run of exactly one stays inline. Hiding "added the build label" behind a click costs
332
881
  * more attention than reading it does.
882
+ *
883
+ * Reversed rather than rendered in GitHub's order, because the question this screen answers is
884
+ * "what just happened", and the answer was at the bottom of everything that happened before it.
333
885
  */
334
886
  function timeline(item, openThread) {
335
887
  const out = [];
@@ -342,13 +894,23 @@ function timeline(item, openThread) {
342
894
  quiet = [];
343
895
  };
344
896
 
345
- for (const e of item.events ?? []) {
897
+ for (const e of [...(item.events ?? [])].reverse()) {
346
898
  if (QUIET.has(e.type)) { quiet.push(e); continue; }
347
899
  flush();
348
900
  if (e.type === "comment") {
349
- out.push(comment(e.actor, e.createdAt, e.body, false, item.repo));
901
+ out.push(
902
+ comment({
903
+ author: e.actor, when: e.createdAt, body: e.body, repo: item.repo,
904
+ reactions: e.reactions,
905
+ }),
906
+ );
350
907
  } else if (e.type === "review" && (e.body ?? "").trim()) {
351
- out.push(comment(e.actor, e.createdAt, e.body, false, item.repo, reviewWord(e.state)));
908
+ out.push(
909
+ comment({
910
+ author: e.actor, when: e.createdAt, body: e.body, repo: item.repo,
911
+ badge: reviewWord(e.state),
912
+ }),
913
+ );
352
914
  } else {
353
915
  out.push(eventLine(e, item, openThread));
354
916
  }
@@ -488,34 +1050,150 @@ function xref(source, item, openThread) {
488
1050
  return b;
489
1051
  }
490
1052
 
491
- function comment(author, when, body, isBody, repo, badge) {
492
- const d = el("div", { className: "cmt" + (isBody ? " first" : "") });
1053
+ function comment({ author, when, body, first, repo, badge, reactions }) {
1054
+ const d = el("div", { className: "cmt" + (first ? " first" : "") });
493
1055
  const meta = el("div", { className: "meta" });
494
1056
  meta.append((author || "?") + " · " + new Date(when).toLocaleString());
495
1057
  if (badge) meta.append(el("span", { className: "pill", style: "margin-left:8px", textContent: badge }));
496
1058
  d.append(meta);
497
1059
  d.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
1060
+ const marks = reactionRow(reactions);
1061
+ if (marks) d.append(marks);
498
1062
  return d;
499
1063
  }
500
1064
 
1065
+ /* GitHub's eight, by their API names. An agent that has picked something up puts 👀 on it, and
1066
+ that acknowledgement is most of why these are worth rendering at all: without it a person
1067
+ posts a comment and has no way to see it landed short of opening GitHub. */
1068
+ const REACTION = {
1069
+ THUMBS_UP: "👍", THUMBS_DOWN: "👎", LAUGH: "😄", HOORAY: "🎉",
1070
+ CONFUSED: "😕", HEART: "❤️", ROCKET: "🚀", EYES: "👀",
1071
+ };
1072
+
1073
+ function reactionRow(reactions) {
1074
+ const list = (reactions ?? []).filter((r) => r.count > 0);
1075
+ if (!list.length) return null;
1076
+ const row = el("div", { className: "reacts" });
1077
+ for (const r of list) {
1078
+ const extra = r.count - r.by.length;
1079
+ row.append(
1080
+ el("span", {
1081
+ className: "react",
1082
+ title: r.by.join(", ") + (extra > 0 ? " and " + extra + " more" : ""),
1083
+ textContent: (REACTION[r.content] ?? "•") + " " + r.count,
1084
+ }),
1085
+ );
1086
+ }
1087
+ return row;
1088
+ }
1089
+
501
1090
  function checkGlyph(state) {
502
1091
  return iconHTML(state === "passing" ? "check" : state === "failing" ? "close" : "dot");
503
1092
  }
504
1093
 
505
1094
  /* --------------------------------- new issue -------------------------------- */
506
1095
 
1096
+ /**
1097
+ * Ask one of the staff for something.
1098
+ *
1099
+ * One question, one dropdown: **who is this for**. It used to be two — a staff member and a
1100
+ * repo — which asked you to know that an issue reaches an agent by landing in their brain repo
1101
+ * *and* saying their `@handle`, and let you set the pair to a combination that reaches nobody.
1102
+ * Neither of those is a decision worth having; both follow from the name you picked.
1103
+ *
1104
+ * So the repo is their brain, the mention goes in the body and is kept in step with the choice,
1105
+ * and where it is going is said on the page rather than chosen on it.
1106
+ */
507
1107
  function newIssueForm(viewer) {
508
- const repos = (S.inbox?.repos ?? []).map((r) => r.owner + "/" + r.name);
509
1108
  const box = el("div");
510
- const repo = el("select");
511
- for (const r of repos) repo.append(el("option", { value: r, textContent: r }));
512
- const title = el("input", { type: "search", placeholder: "Title", style: "flex:1;min-width:240px" });
513
- const body = el("textarea", { placeholder: "Body (markdown)", rows: 8 });
514
- const labels = el("input", { type: "search", placeholder: "Labels, comma separated" });
1109
+ const title = el("input", { type: "search", placeholder: "Title", style: "width:100%" });
1110
+ const body = el("textarea", {
1111
+ placeholder: "What do you want them to do? (markdown)",
1112
+ rows: 8,
1113
+ value: "",
1114
+ });
515
1115
  const status = el("span", { className: "meta" });
516
- const create = el("button", { className: "ghbtn primary", textContent: "Create issue" });
1116
+ const create = el("button", { className: "ghbtn primary", textContent: "Send it" });
1117
+
1118
+ const mentionOf = (s) => s.mention ?? "@" + s.handle;
1119
+ const chosen = () => S.data.staff.find((x) => x.handle === who.value) ?? null;
1120
+ const repoOf = () => chosen()?.brain ?? "";
1121
+
1122
+ const picked = labelPicker(repoOf);
1123
+ const files = attachBox(body, repoOf);
1124
+ // The recipient's mention is written in for you; this is for the second one, when an issue
1125
+ // needs somebody else to see it too.
1126
+ attachMentions(body);
1127
+
1128
+ const who = el("select", { title: "Whose brain this goes to" });
1129
+ who.append(
1130
+ ...S.data.staff.map((s) =>
1131
+ el("option", {
1132
+ value: s.handle,
1133
+ textContent: s.name + (s.brain ? "" : " · no brain repo"),
1134
+ }),
1135
+ ),
1136
+ );
1137
+ if (!S.data.staff.length) {
1138
+ who.append(el("option", { value: "", textContent: "nobody has been hired yet" }));
1139
+ }
1140
+ who.value = S.inboxStaff || S.data.staff[0]?.handle || "";
1141
+
1142
+ /* The mention is the mechanism: their workflow gates on it, so a body without one is a note
1143
+ nobody is woken by. It is written in for you, and rewritten when you change your mind —
1144
+ leaving the last person's `@handle` at the top would send the issue to one staff member
1145
+ and address it to another. */
1146
+ const MENTIONS = S.data.staff.map(mentionOf);
1147
+ const retarget = (to) => {
1148
+ const at = mentionOf(to);
1149
+ let text = String(body.value ?? "");
1150
+ for (const old of MENTIONS) {
1151
+ if (old === at) continue;
1152
+ text = text.replace(new RegExp("^\\s*" + old.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*"), "");
1153
+ }
1154
+ body.value = text.includes(at) ? text : at + " " + text.replace(/^\s+/, "");
1155
+ };
1156
+
1157
+ who.onchange = () => {
1158
+ const s = chosen();
1159
+ if (!s) return;
1160
+ retarget(s);
1161
+ picked.load();
1162
+ hint();
1163
+ };
1164
+
1165
+ /* Where it lands and what wakes them, said out loud. Somebody who deletes the mention from
1166
+ the body has quietly turned a request into a note, and this is the only thing that says so. */
1167
+ const forWhom = el("p", { className: "meta", style: "margin:9px 0 0" });
1168
+ const hint = () => {
1169
+ const s = chosen();
1170
+ if (!s) {
1171
+ forWhom.textContent = "There is nobody to send this to yet. Hire a staff member first.";
1172
+ create.disabled = true;
1173
+ return;
1174
+ }
1175
+ if (!s.brain) {
1176
+ forWhom.textContent =
1177
+ s.name + " has no brain repo in their manifest, so there is nowhere to put this.";
1178
+ create.disabled = true;
1179
+ return;
1180
+ }
1181
+ create.disabled = false;
1182
+ forWhom.textContent =
1183
+ "Goes to " + s.brain + ". " +
1184
+ (String(body.value ?? "").includes(mentionOf(s))
1185
+ ? mentionOf(s) + " in the body is what wakes them, usually within a minute."
1186
+ : "Put " + mentionOf(s) + " back in the body, or nothing will wake them.");
1187
+ };
1188
+ body.addEventListener("input", hint);
1189
+
1190
+ if (chosen()) retarget(chosen());
1191
+ picked.load();
1192
+ hint();
517
1193
 
518
1194
  create.onclick = async () => {
1195
+ const s = chosen();
1196
+ if (!s?.brain) return;
519
1197
  if (!title.value.trim()) { title.focus(); return; }
520
1198
  create.disabled = true;
521
1199
  status.textContent = "creating…";
@@ -523,10 +1201,10 @@ function newIssueForm(viewer) {
523
1201
  try {
524
1202
  const r = await post({
525
1203
  action: "create",
526
- repo: repo.value,
1204
+ repo: s.brain,
527
1205
  title: title.value,
528
1206
  body: body.value,
529
- labels: labels.value.split(",").map((s) => s.trim()).filter(Boolean),
1207
+ labels: picked.chosen(),
530
1208
  });
531
1209
  status.innerHTML =
532
1210
  'created · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">open it</a>';
@@ -539,11 +1217,169 @@ function newIssueForm(viewer) {
539
1217
  };
540
1218
 
541
1219
  box.append(
542
- el("h3", { style: "margin:0 0 12px;font:600 16px var(--sans)", textContent: "New issue" }),
543
- el("div", { className: "row", style: "margin-bottom:9px" }, [repo, title]),
1220
+ el("h3", { style: "margin:0 0 12px;font:600 16px var(--sans)", textContent: "Ask a staff member" }),
1221
+ el("div", { className: "row", style: "margin-bottom:9px" }, [
1222
+ el("label", { className: "meta", textContent: "For", htmlFor: "newissue-who" }),
1223
+ Object.assign(who, { id: "newissue-who" }),
1224
+ ]),
1225
+ el("div", { className: "row", style: "margin-bottom:9px" }, [title]),
544
1226
  body,
545
- labels,
1227
+ forWhom,
1228
+ files.node,
1229
+ picked.node,
546
1230
  el("div", { className: "row", style: "margin-top:10px" }, [create, status]),
547
1231
  );
548
1232
  viewer.replaceChildren(box);
549
1233
  }
1234
+
1235
+ /* --------------------------------- labels --------------------------------- */
1236
+
1237
+ /**
1238
+ * The repo's own labels, as toggles.
1239
+ *
1240
+ * This used to be a text box you typed comma-separated names into, which is a spelling test:
1241
+ * `from-cmo` and `from-CMO` are different labels and only one of them exists. The set is small
1242
+ * and it is knowable, so it is offered instead.
1243
+ *
1244
+ * When GitHub cannot be reached the labels already on items in the inbox stand in. They are
1245
+ * not the whole vocabulary, but they are real, and a picker with the common ones beats a
1246
+ * disabled one.
1247
+ */
1248
+ function labelPicker(repoOf) {
1249
+ const node = el("div", { className: "labelpick" });
1250
+ const head = el("div", { className: "meta", textContent: "Labels" });
1251
+ const wrap = el("div", { className: "chips" });
1252
+ node.append(head, wrap);
1253
+ const chosen = new Set();
1254
+
1255
+ const paint = (labels, note) => {
1256
+ wrap.replaceChildren();
1257
+ head.textContent = "Labels" + (note ? " · " + note : "");
1258
+ if (!labels.length) {
1259
+ wrap.append(el("span", { className: "meta", textContent: "none on this repo" }));
1260
+ return;
1261
+ }
1262
+ for (const name of labels) {
1263
+ const b = el("button", { className: "chip pick " + tone(name), textContent: name });
1264
+ b.type = "button";
1265
+ b.setAttribute("aria-pressed", String(chosen.has(name)));
1266
+ b.onclick = () => {
1267
+ if (chosen.has(name)) chosen.delete(name);
1268
+ else chosen.add(name);
1269
+ b.setAttribute("aria-pressed", String(chosen.has(name)));
1270
+ };
1271
+ wrap.append(b);
1272
+ }
1273
+ };
1274
+
1275
+ const seen = (repo) => [
1276
+ ...new Set((S.inbox?.items ?? []).filter((i) => i.repo === repo).flatMap((i) => i.labels ?? [])),
1277
+ ].sort();
1278
+
1279
+ const load = async () => {
1280
+ const repo = repoOf();
1281
+ chosen.clear();
1282
+ if (!repo) { paint([]); return; }
1283
+ paint([], "asking GitHub…");
1284
+ try {
1285
+ const r = await getLabels(repo);
1286
+ if (r.error) paint(seen(repo), "GitHub said: " + r.error);
1287
+ else paint((r.labels ?? []).map((l) => l.name));
1288
+ } catch {
1289
+ paint(seen(repo), "offline, showing labels already in use");
1290
+ }
1291
+ };
1292
+
1293
+ return { node, load, chosen: () => [...chosen] };
1294
+ }
1295
+
1296
+ /* ------------------------------- attachments ------------------------------- */
1297
+
1298
+ /**
1299
+ * Files onto an issue.
1300
+ *
1301
+ * GitHub's own drag-and-drop attachments are minted by its web app and cannot be made with
1302
+ * `gh`, so a file dropped here is committed into the repo the issue is in and linked. That is
1303
+ * the better answer for this org anyway: every run clones the repo, so an agent opens the
1304
+ * screenshot off its own disk instead of being handed a URL it has no token for.
1305
+ *
1306
+ * Drop, pick or paste — paste is the one that matters, because a screenshot is on the
1307
+ * clipboard and never on disk.
1308
+ */
1309
+ function attachBox(ta, repoOf) {
1310
+ const node = el("div", { className: "attach" });
1311
+ const pick = el("input", { type: "file", multiple: true, hidden: true });
1312
+ const btn = el("button", { className: "ghbtn", type: "button" });
1313
+ btn.append(icon("paperclip", "ic"), el("span", { textContent: "Attach files" }));
1314
+ const status = el("span", { className: "meta", textContent: "or drop them on the box above" });
1315
+ node.append(pick, btn, status);
1316
+
1317
+ const say = (text, bad) => {
1318
+ status.textContent = text;
1319
+ status.className = bad ? "meta err" : "meta";
1320
+ };
1321
+
1322
+ /* Written at the cursor, so an attachment lands where you were typing rather than at the
1323
+ end of whatever you had written. The repo path goes in beside the link: the link is for
1324
+ the person, the path is for the model. */
1325
+ const insert = (a) => {
1326
+ const name = a.path.split("/").pop();
1327
+ const media = /\.(png|jpe?g|gif|svg|webp|mp4|mov|webm|m4v)$/i.test(name);
1328
+ const text = (media ? "!" : "") + "[" + name + "](" + a.url + ")\n`" + a.path + "`\n";
1329
+ const at = ta.selectionStart ?? ta.value.length;
1330
+ const before = ta.value.slice(0, at);
1331
+ ta.value = before + (before && !before.endsWith("\n") ? "\n" : "") + text + ta.value.slice(at);
1332
+ ta.selectionStart = ta.selectionEnd = before.length + text.length + 1;
1333
+ ta.focus();
1334
+ };
1335
+
1336
+ const send = async (list) => {
1337
+ const repo = repoOf();
1338
+ if (!repo) { say("pick a repo first", true); return; }
1339
+ const files = [...list].filter((f) => f.size);
1340
+ if (!files.length) return;
1341
+ btn.disabled = true;
1342
+ for (const [n, f] of files.entries()) {
1343
+ say("uploading " + f.name + (files.length > 1 ? " (" + (n + 1) + "/" + files.length + ")" : "") + "…");
1344
+ try {
1345
+ const a = await upload(repo, f);
1346
+ insert(a);
1347
+ say(
1348
+ a.pushed
1349
+ ? "committed " + a.path + " to " + repo
1350
+ : "committed locally but not pushed: " + a.note,
1351
+ !a.pushed,
1352
+ );
1353
+ } catch (e) {
1354
+ say(f.name + ": " + e.message, true);
1355
+ break;
1356
+ }
1357
+ }
1358
+ btn.disabled = false;
1359
+ pick.value = "";
1360
+ };
1361
+
1362
+ btn.onclick = () => pick.click();
1363
+ pick.onchange = () => send(pick.files);
1364
+
1365
+ for (const zone of [node, ta]) {
1366
+ zone.addEventListener("dragover", (e) => {
1367
+ e.preventDefault();
1368
+ ta.classList.add("dropping");
1369
+ });
1370
+ zone.addEventListener("dragleave", () => ta.classList.remove("dropping"));
1371
+ zone.addEventListener("drop", (e) => {
1372
+ e.preventDefault();
1373
+ ta.classList.remove("dropping");
1374
+ send(e.dataTransfer?.files ?? []);
1375
+ });
1376
+ }
1377
+ ta.addEventListener("paste", (e) => {
1378
+ const files = [...(e.clipboardData?.files ?? [])];
1379
+ if (!files.length) return;
1380
+ e.preventDefault();
1381
+ send(files);
1382
+ });
1383
+
1384
+ return { node };
1385
+ }