@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4

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