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

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 (97) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4562 -2744
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +90 -5
  17. package/docs/concepts.md +48 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +8 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +126 -77
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +39 -2
  33. package/docs/portal.md +119 -44
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +49 -17
  37. package/docs/staff-yaml.md +14 -2
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/strategy/ideas.md +7 -0
  48. package/templates/briefs/priorities.md +46 -0
  49. package/templates/ops/.github/workflows/session.yaml +108 -14
  50. package/templates/ops/agents.mjs +7 -3
  51. package/templates/ops/compose.mjs +16 -3
  52. package/templates/ops/inflight.mjs +157 -0
  53. package/templates/ops/org/operating.md +21 -1
  54. package/templates/ops/org/voice.md +9 -0
  55. package/templates/ops/prompts/_inflight.md +14 -0
  56. package/templates/ops/prompts/_paths.md +2 -1
  57. package/templates/ops/prompts/daily.md +16 -7
  58. package/templates/ops/prompts/mention.md +2 -0
  59. package/templates/ops/run-record.mjs +144 -0
  60. package/templates/portal/css/base.css +146 -73
  61. package/templates/portal/css/brain.css +23 -20
  62. package/templates/portal/css/diff.css +10 -9
  63. package/templates/portal/css/graph.css +12 -7
  64. package/templates/portal/css/health.css +13 -11
  65. package/templates/portal/css/inbox.css +57 -25
  66. package/templates/portal/css/layout.css +96 -41
  67. package/templates/portal/css/markdown.css +36 -14
  68. package/templates/portal/css/runs.css +13 -0
  69. package/templates/portal/css/setup.css +116 -34
  70. package/templates/portal/index.html +18 -2
  71. package/templates/portal/js/api.js +41 -4
  72. package/templates/portal/js/app.js +132 -9
  73. package/templates/portal/js/dialog.js +82 -0
  74. package/templates/portal/js/icons.js +37 -0
  75. package/templates/portal/js/inflight.js +18 -0
  76. package/templates/portal/js/md.js +5 -2
  77. package/templates/portal/js/mdedit.js +84 -0
  78. package/templates/portal/js/readiness.js +35 -0
  79. package/templates/portal/js/refresh.js +2 -1
  80. package/templates/portal/js/state.js +17 -4
  81. package/templates/portal/js/views/app.js +24 -7
  82. package/templates/portal/js/views/checklist.js +10 -4
  83. package/templates/portal/js/views/credential.js +84 -0
  84. package/templates/portal/js/views/graph.js +1 -1
  85. package/templates/portal/js/views/health.js +17 -4
  86. package/templates/portal/js/views/hire.js +583 -0
  87. package/templates/portal/js/views/inbox.js +264 -84
  88. package/templates/portal/js/views/org.js +46 -106
  89. package/templates/portal/js/views/orgedit.js +234 -0
  90. package/templates/portal/js/views/paste.js +87 -21
  91. package/templates/portal/js/views/prompt.js +11 -4
  92. package/templates/portal/js/views/repos.js +20 -15
  93. package/templates/portal/js/views/runonce.js +94 -0
  94. package/templates/portal/js/views/runs.js +170 -0
  95. package/templates/portal/js/views/setup.js +257 -75
  96. package/templates/portal/js/views/staff.js +158 -243
  97. package/templates/portal/js/views/todo.js +62 -0
@@ -1,9 +1,9 @@
1
1
  /* The inbox: everything open across the org, and the thread beside it. */
2
2
 
3
3
  import { getLabels, getPr, getThread, post, upload } from "../api.js";
4
- import { askText } from "../dialog.js";
4
+ import { askText, askYes } from "../dialog.js";
5
5
  import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
6
- import { icon, iconHTML } from "../icons.js";
6
+ import { icon, iconHTML, staffIcon } from "../icons.js";
7
7
  import { mdlite } from "../md.js";
8
8
  import { attachMentions } from "../mention.js";
9
9
  import { ensureInbox, refreshAll, stampCounts } from "../refresh.js";
@@ -92,6 +92,10 @@ export function belongsTo(item, s) {
92
92
  return false;
93
93
  }
94
94
 
95
+ /* Threads read so far, by repo#number, kept for the session. Each is reused while its
96
+ updatedAt matches the list's, so a thread is fetched again only when it has changed. */
97
+ const THREADS = new Map();
98
+
95
99
  export const viewInbox = (m) => inboxScreen(m, {});
96
100
 
97
101
  /**
@@ -113,7 +117,7 @@ function inboxScreen(m, opts) {
113
117
  className: "sub",
114
118
  textContent: opts.prs
115
119
  ? "Work the staff have finished and cannot land themselves."
116
- : "Everything open across the org.",
120
+ : "Everything open across the org. Pull requests are in Pending work.",
117
121
  });
118
122
  m.append(sub);
119
123
 
@@ -122,42 +126,25 @@ function inboxScreen(m, opts) {
122
126
  placeholder: "Filter by title, label, repo…",
123
127
  value: S.query,
124
128
  });
125
- const scope = el("select");
126
- scope.append(
127
- el("option", { value: "", textContent: "Everything" }),
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
- ]),
136
- );
137
- scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
138
-
139
- /* Open only by default. An inbox is what is waiting on somebody, and burying that under
140
- five months of finished work would be answering a different question. */
141
- const state = el("select", { title: "Which items to list" });
142
- state.append(
143
- el("option", { value: "", textContent: "Open" }),
144
- el("option", { value: "closed", textContent: "Recently closed" }),
145
- el("option", { value: "all", textContent: "Open and closed" }),
146
- );
147
- state.value = S.inboxState;
148
-
149
- const whose = el("select", {
150
- title: "Whose work: their brain repo, anything their bot wrote, and anything addressed to them",
151
- });
152
- whose.append(
153
- el("option", { value: "", textContent: "Everyone" }),
154
- ...S.data.staff.map((s) => el("option", { value: s.handle, textContent: s.name })),
155
- );
156
- whose.value = S.inboxStaff;
129
+ /* Open or closed, as a toggle. Who and what (a staff member, what is on you, decisions)
130
+ moved to the sidebar under Inbox, where a row of dropdowns used to be. */
131
+ if (S.inboxState === "all") S.inboxState = "";
132
+ const toggle = el("div", { className: "modes inboxstate" });
133
+ for (const [value, label] of [["", "Open"], ["closed", "Closed"]]) {
134
+ const b = el("button", { className: "mode", textContent: label });
135
+ b.classList.toggle("on", (S.inboxState || "") === value);
136
+ b.onclick = () => {
137
+ S.inboxState = value;
138
+ for (const x of toggle.children) x.classList.toggle("on", x === b);
139
+ writeHash(false);
140
+ paint();
141
+ };
142
+ toggle.append(b);
143
+ }
157
144
 
158
145
  const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
159
146
  refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
160
- const controls = [search, whose, state, scope, refresh];
147
+ const controls = [search, toggle, refresh];
161
148
  // A pull request comes from a branch, so there is nothing here that could open one.
162
149
  if (!opts.prs) {
163
150
  const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
@@ -167,7 +154,7 @@ function inboxScreen(m, opts) {
167
154
  };
168
155
  controls.push(newBtn);
169
156
  }
170
- m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
157
+ m.append(el("div", { className: "row inboxbar", style: "margin-bottom:16px" }, controls));
171
158
 
172
159
  const split = el("div", { className: "split" });
173
160
  const list = el("div", { className: "tree" });
@@ -176,9 +163,6 @@ function inboxScreen(m, opts) {
176
163
  m.append(split);
177
164
 
178
165
  search.oninput = () => { S.query = search.value; writeHash(false); paint(); };
179
- scope.onchange = () => { S.inboxFilter = scope.value; writeHash(false); paint(); };
180
- whose.onchange = () => { S.inboxStaff = whose.value; writeHash(false); paint(); };
181
- state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
182
166
  refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
183
167
 
184
168
  if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
@@ -187,9 +171,9 @@ function inboxScreen(m, opts) {
187
171
  acted on, because only the already-loaded branch opened one. */
188
172
  function restore() {
189
173
  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") {
174
+ // The two screens share one open thread, and each holds half the org. Whatever was open on
175
+ // the other one is not in this list, so it is dropped rather than opened beside it.
176
+ if (!!opts.prs !== (S.inboxOpen.kind === "pr")) {
193
177
  S.inboxOpen = null;
194
178
  writeHash(false);
195
179
  return;
@@ -250,10 +234,23 @@ function inboxScreen(m, opts) {
250
234
  // "On you" is on any of the humans this org answers to, not only the first one.
251
235
  const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
252
236
 
253
- const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
237
+ // The sidebar's filters are the inbox's. Pending work has no control for them, so it ignores them.
238
+ const staffPick = opts.prs ? "" : S.inboxStaff;
239
+ // "Issues" is the product repos: every repo a staff member works in.
240
+ const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
241
+ const issuesOnly = staffPick === "@issues";
242
+ const unreadOnly = staffPick === "@unread";
243
+ const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
244
+ /* The two screens partition the org rather than overlap on it. Pending work is every pull
245
+ request; the inbox is everything else. An inbox that also listed the PRs said the same
246
+ thing twice and made the badge beside it a number you could not act on. */
254
247
  const mine = S.inbox.items
255
- .filter((i) => belongsTo(i, whoseStaff))
256
- .filter((i) => !opts.prs || i.kind === "pr");
248
+ /* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
249
+ on who filed or was named put an issue the CMO filed for the CTO under both of them. */
250
+ .filter((i) =>
251
+ unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
252
+ )
253
+ .filter((i) => (i.kind === "pr") === !!opts.prs);
257
254
  const isOpen = (i) => i.state === "OPEN";
258
255
  const scoped = mine.filter(
259
256
  S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
@@ -263,14 +260,20 @@ function inboxScreen(m, opts) {
263
260
  (i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
264
261
  .toLowerCase().includes(q),
265
262
  );
266
- if (S.inboxFilter === "mine") items = items.filter(onAHuman);
267
- else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
268
- else if (S.inboxFilter === "pr") items = items.filter((i) => i.kind === "pr");
263
+ const filter = opts.prs ? "" : S.inboxFilter;
264
+ if (filter === "mine") items = items.filter(onAHuman);
265
+ else if (filter === "decision") items = items.filter((i) => i.labels.includes("decision"));
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. */
269
268
 
270
269
  const open = mine.filter(isOpen);
271
270
  const onYou = open.filter(onAHuman).length;
272
- const prs = open.filter((i) => i.kind === "pr").length;
273
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;
274
277
  const where = whoseStaff
275
278
  ? " for <b>" + esc(whoseStaff.name) + "</b>"
276
279
  : " across " + (S.inbox.repos ?? []).length + " repos";
@@ -286,7 +289,7 @@ function inboxScreen(m, opts) {
286
289
  (failing ? " · " + failing + " failing" : "") +
287
290
  (S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
288
291
  : open.length + " open" + where + " · <b>" + onYou + " on " +
289
- esc(humanLabel()) + "</b> · " + prs + " waiting on a merge" +
292
+ esc(humanLabel()) + "</b> · " + prs + " in Pending work" +
290
293
  (S.inboxState === "all" ? " · " + shut + " closed" : "")) +
291
294
  (S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
292
295
 
@@ -295,9 +298,15 @@ function inboxScreen(m, opts) {
295
298
  list.append(el("div", { className: "tdir err", textContent: e }));
296
299
  }
297
300
 
298
- // Newest first, flat. Grouping by repo buried a fresh comment under whichever project
299
- // happened to sort first; the project moves onto the row instead.
300
- items = items.slice().sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
301
+ // Unread first, then newest first, flat. Grouping by repo buried a fresh comment under
302
+ // whichever project happened to sort first; the project moves onto the row instead.
303
+ items = items
304
+ .slice()
305
+ .sort(
306
+ (a, b) =>
307
+ Number(Boolean(b.unread)) - Number(Boolean(a.unread)) ||
308
+ String(b.updatedAt).localeCompare(String(a.updatedAt)),
309
+ );
301
310
  if (!items.length) {
302
311
  list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
303
312
  return;
@@ -305,7 +314,7 @@ function inboxScreen(m, opts) {
305
314
 
306
315
  for (const i of items) {
307
316
  const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
308
- const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
317
+ const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
309
318
  const yours = onAHuman(i);
310
319
  const chips = i.labels
311
320
  .slice(0, 3)
@@ -324,11 +333,17 @@ function inboxScreen(m, opts) {
324
333
  '<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
325
334
  (i.checks && i.checks !== "none"
326
335
  ? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
336
+ /* Green checks on a branch that no longer applies is the one case where the row
337
+ reads as ready and is not. It has to say so here, not three clicks in. */
338
+ (i.mergeable === "CONFLICTING"
339
+ ? '<span class="chip conflict" title="This branch conflicts with its base">' +
340
+ "conflicts</span>"
341
+ : "") +
327
342
  // How much conversation is on a thread, which is most of what tells a live one from
328
343
  // 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>"
344
+ ((i.commentCount ?? i.comments.length)
345
+ ? '<span class="cc" title="' + (i.commentCount ?? i.comments.length) + ' comments">' +
346
+ iconHTML("review") + (i.commentCount ?? i.comments.length) + "</span>"
332
347
  : "") +
333
348
  chips +
334
349
  '<span class="when">' + ago(i.updatedAt) + "</span>" +
@@ -358,18 +373,63 @@ function inboxScreen(m, opts) {
358
373
  }
359
374
  const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
360
375
  if (row) markCurrent(list, row);
376
+ // Opening it is reading it: cleared here at once, and on GitHub in the background.
377
+ if (item.unread) {
378
+ const thread = item.unread.thread;
379
+ item.unread = null;
380
+ row?.classList.remove("unread");
381
+ stampCounts();
382
+ post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
383
+ }
384
+
385
+ /* The list carries no bodies or timelines (carrying them cost most of an hour's GitHub
386
+ allowance per load), so a thread is read when it is opened, and kept until GitHub says
387
+ it has changed. Reopening one costs nothing. */
388
+ if (item.partial) {
389
+ const key = item.repo + "#" + item.number;
390
+ const kept = THREADS.get(key);
391
+ const keep = { role: item.role, unread: item.unread };
392
+ if (kept && kept.updatedAt === item.updatedAt) {
393
+ Object.assign(item, kept, keep, { partial: false });
394
+ } else {
395
+ viewer.dataset.want = key;
396
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
397
+ getThread(item.repo, item.number, item.kind)
398
+ .then((fresh) => {
399
+ if (!fresh || fresh.error) throw new Error(fresh?.error ?? "could not read it");
400
+ THREADS.set(key, fresh);
401
+ Object.assign(item, fresh, keep, { partial: false });
402
+ })
403
+ .catch((e) => {
404
+ item.partial = false;
405
+ item.loadError = String(e.message || e);
406
+ })
407
+ .finally(() => {
408
+ // Somebody may have opened another thread while this one was loading.
409
+ if (viewer.dataset.want === key) openThread(ref);
410
+ });
411
+ return;
412
+ }
413
+ }
361
414
 
362
415
  const head = el("div", { className: "thead" });
363
416
  head.innerHTML =
364
417
  '<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
365
418
  ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
366
- " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
419
+ " · " + esc(item.author) + " · " + ago(item.updatedAt) +
420
+ // The labels sit on this line, after the time, rather than taking a line of their own.
421
+ (item.labels.length
422
+ ? '<span class="tmetalabels">' +
423
+ item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
424
+ "</span>"
425
+ : "") +
426
+ "</div>" +
367
427
  "<h3>" + esc(item.title) + "</h3>" +
368
- (item.labels.length
369
- ? '<div class="row tlabels">' +
370
- item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
371
- "</div>"
372
- : "");
428
+ (item.mergeable === "CONFLICTING"
429
+ ? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
430
+ "until someone rebases or merges the base into it.</p>"
431
+ : "") +
432
+ "";
373
433
  head.append(threadActions(item));
374
434
  delete viewer.dataset.wait;
375
435
  viewer.replaceChildren(head);
@@ -380,6 +440,11 @@ function inboxScreen(m, opts) {
380
440
  for a wrapper that would only ever hold one thing. */
381
441
  const pane = item.kind === "pr" ? el("div") : viewer;
382
442
  if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
443
+ if (item.loadError) {
444
+ pane.append(el("p", { className: "err", textContent: "Couldn't load the conversation: " + item.loadError }));
445
+ delete item.loadError;
446
+ item.partial = true; // so opening it again tries again
447
+ }
383
448
  conversation(pane, item);
384
449
  viewer.scrollTop = 0;
385
450
  }
@@ -527,8 +592,11 @@ function inboxScreen(m, opts) {
527
592
  b.onclick = async () => {
528
593
  b.disabled = true;
529
594
  try {
530
- const r = await openReply(item, f);
531
- if (r) b.textContent = r.warning ? "sent, with a warning" : "sent";
595
+ // Same reason as the header's Reply: the wait is after the box closes, not during it.
596
+ const r = await openReply(item, f, () => {
597
+ b.textContent = "Replying…";
598
+ });
599
+ b.textContent = r ? (r.warning ? "sent, with a warning" : "sent") : "Reply";
532
600
  } catch (e) {
533
601
  b.textContent = e.message;
534
602
  }
@@ -590,24 +658,43 @@ function inboxScreen(m, opts) {
590
658
  /* One button, because there was no way to tell the two apart. Reply reaches whoever you
591
659
  named in it; see `openReply`. */
592
660
  const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
661
+
662
+ /* Posting a comment is a round trip to GitHub through `gh`, and on a cold connection that
663
+ is seconds of a button that still says "Reply" and still takes clicks. Saying nothing
664
+ reads as a page that ignored you, which is how the same comment gets sent twice. */
665
+ const sending = (on) => {
666
+ busy(on, on ? "sending…" : "");
667
+ reply.textContent = on ? "Replying…" : "Reply";
668
+ };
669
+
593
670
  reply.onclick = async () => {
594
671
  let r;
595
672
  try {
596
- r = await openReply(item, null);
673
+ r = await openReply(item, null, () => sending(true));
597
674
  } catch (e) {
675
+ sending(false);
598
676
  failed(e);
599
677
  return;
600
678
  }
601
- if (!r) return;
679
+ // Closed the box without sending: nothing ever started, so there is nothing to put back.
680
+ if (!r) {
681
+ sending(false);
682
+ return;
683
+ }
602
684
  /* Repaint only when something was said on this thread, which is always true here: `ask`
603
685
  posts the comment too. A warning means the tracker issue went up and the comment did
604
686
  not, and then the status line is the only thing that says so. */
605
687
  if (r.warning) {
688
+ sending(false);
606
689
  status.className = "meta err";
607
690
  status.textContent = r.warning;
608
691
  return;
609
692
  }
693
+ /* Held across the reload as well as the post. The reply is not on screen until the
694
+ thread repaints, and a button that said "Reply" again before then would be claiming
695
+ it was done over a thread that still did not show it. */
610
696
  await reloadThread(item);
697
+ sending(false);
611
698
  };
612
699
  buttons.push(reply);
613
700
 
@@ -638,7 +725,20 @@ function inboxScreen(m, opts) {
638
725
  body: body || undefined,
639
726
  });
640
727
  DRAFTS.delete(key);
641
- await refreshAll(false);
728
+ /* What changed is known, so it is changed here rather than re-reading every repo,
729
+ which redrew the whole inbox for one issue. The next refresh confirms it. */
730
+ const me = humansOf()[0]?.github ?? "you";
731
+ const now = new Date().toISOString();
732
+ item.events = [
733
+ ...(item.events ?? []),
734
+ ...(body ? [{ type: "comment", actor: me, createdAt: now, body }] : []),
735
+ { type: closing ? "closed" : "reopened", actor: me, createdAt: now },
736
+ ];
737
+ item.state = closing ? "CLOSED" : "OPEN";
738
+ item.updatedAt = now;
739
+ stampCounts();
740
+ paint();
741
+ openThread({ repo: item.repo, number: item.number, kind: item.kind });
642
742
  } catch (e) { failed(e); }
643
743
  };
644
744
  buttons.push(closeBtn);
@@ -651,12 +751,27 @@ function inboxScreen(m, opts) {
651
751
  own settings. The server reads it from there. */
652
752
  if (item.kind === "pr" && item.state === "OPEN") {
653
753
  const merge = el("button", { className: "ghbtn", textContent: "Merge" });
754
+ /* A conflicted branch cannot be merged by anyone, so the button says why instead of
755
+ spending a round trip to come back with GitHub's version of the same sentence. */
756
+ if (item.mergeable === "CONFLICTING") {
757
+ merge.disabled = true;
758
+ merge.title = "This branch conflicts with its base";
759
+ }
654
760
  merge.onclick = async () => {
655
- if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
761
+ const yes = await askYes({
762
+ title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
763
+ confirm: "Merge",
764
+ });
765
+ if (!yes) return;
656
766
  busy(true, "merging…");
657
767
  try {
658
768
  await post({ action: "merge", repo: item.repo, number: item.number });
659
- await refreshAll(false);
769
+ // As with closing: the change is known, so it is made here rather than re-reading every repo.
770
+ item.state = "MERGED";
771
+ item.updatedAt = new Date().toISOString();
772
+ stampCounts();
773
+ paint();
774
+ openThread({ repo: item.repo, number: item.number, kind: item.kind });
660
775
  } catch (e) { failed(e); }
661
776
  };
662
777
  buttons.push(merge);
@@ -686,7 +801,12 @@ function inboxScreen(m, opts) {
686
801
  const at = S.inbox.items.findIndex(
687
802
  (i) => i.repo === item.repo && i.number === item.number,
688
803
  );
689
- if (at >= 0) S.inbox.items[at] = { ...S.inbox.items[at], ...fresh };
804
+ if (at >= 0) {
805
+ const old = S.inbox.items[at];
806
+ THREADS.set(item.repo + "#" + item.number, fresh);
807
+ // A single thread does not know which part of the org it is in, or whether it is unread.
808
+ S.inbox.items[at] = { ...old, ...fresh, role: old.role, unread: old.unread, partial: false };
809
+ }
690
810
  }
691
811
  } catch {
692
812
  /* the comment posted; a stale pane is not worth an error */
@@ -714,7 +834,28 @@ function inboxScreen(m, opts) {
714
834
  * @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
715
835
  * @returns the result of the write, or null if nothing was written.
716
836
  */
717
- async function openReply(item, anchor) {
837
+ /**
838
+ * @param onSend Called once, the moment the box is submitted and the writing starts.
839
+ *
840
+ * The dialog and the send are one `await` to whoever called this, so a caller cannot tell the
841
+ * two apart by itself — and the two want opposite things from a button. While the box is open
842
+ * you are still typing and the button has to stay as it was; once it closes there is a request
843
+ * in flight against GitHub that can take seconds, and that is what needs saying.
844
+ */
845
+ /**
846
+ * Who a reply is for, by default: the staff member whose tracker the issue is on, or whose App
847
+ * opened it. They are only woken by an @mention, so a reply without one reached nobody.
848
+ */
849
+ export function addressee(item) {
850
+ const staff = S.data?.staff ?? [];
851
+ const s =
852
+ staff.find((x) => x.brain === item.repo) ??
853
+ staff.find((x) => (x.soloBots ?? []).includes(item.author));
854
+ if (!s) return "";
855
+ return (s.mention || "@" + s.handle) + " ";
856
+ }
857
+
858
+ async function openReply(item, anchor, onSend) {
718
859
  const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
719
860
  let deaf = null;
720
861
 
@@ -722,7 +863,7 @@ async function openReply(item, anchor) {
722
863
  title: "Reply to " + item.repo + " #" + item.number,
723
864
  hint: anchor ? anchor.path : item.title,
724
865
  confirm: "Comment",
725
- value: DRAFTS.get(key) ?? "",
866
+ value: DRAFTS.get(key) ?? addressee(item),
726
867
  /* It goes out through whoever's `gh` is signed in here, which is only knowably one person
727
868
  when the org has one. With two it would be a guess, and a wrong name on a reply box is
728
869
  worse than no name. */
@@ -733,10 +874,15 @@ async function openReply(item, anchor) {
733
874
  else DRAFTS.delete(key);
734
875
  };
735
876
  deaf = deafNote(ta, item, anchor);
877
+ // Typing starts after the @mention, not in front of it.
878
+ setTimeout(() => {
879
+ ta.selectionStart = ta.selectionEnd = ta.value.length;
880
+ }, 0);
736
881
  return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
737
882
  },
738
883
  });
739
884
  if (!body) return null;
885
+ onSend?.();
740
886
 
741
887
  /* One press, both writes. `ask` posts the comment itself when told to, so these are not two
742
888
  requests racing: the tracker issue goes up first because it is the half that actually
@@ -1000,15 +1146,36 @@ function xref(source, item, openThread) {
1000
1146
  return b;
1001
1147
  }
1002
1148
 
1149
+ /* Who wrote something, as a person would say it: a staff member by their role, with their
1150
+ icon; one of the humans by name; anyone else by their login. */
1151
+ function whoWrote(login) {
1152
+ const s = (S.data?.staff ?? []).find((x) => (x.soloBots ?? []).includes(login));
1153
+ if (s) return { name: s.name, glyph: staffIcon(s), kind: "staff" };
1154
+ if (isHuman(login)) {
1155
+ const h = humansOf().find((x) => x.github === login);
1156
+ return { name: h?.name ?? login, glyph: "person", kind: "human" };
1157
+ }
1158
+ return { name: login || "?", glyph: "person", kind: "other" };
1159
+ }
1160
+
1161
+ /* A comment is a card: a header strip saying who and when, then what they said. As text
1162
+ between thin rules, it was hard to see where one message ended and the next began. */
1003
1163
  function comment({ author, when, body, first, repo, badge, reactions }) {
1004
- const d = el("div", { className: "cmt" + (first ? " first" : "") });
1005
- const meta = el("div", { className: "meta" });
1006
- meta.append((author || "?") + " · " + new Date(when).toLocaleString());
1007
- if (badge) meta.append(el("span", { className: "pill", style: "margin-left:8px", textContent: badge }));
1008
- d.append(meta);
1009
- d.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
1164
+ const who = whoWrote(author);
1165
+ const d = el("div", { className: "cmt " + who.kind + (first ? " first" : "") });
1166
+ const head = el("div", { className: "chead" });
1167
+ head.append(icon(who.glyph, "ic"), el("b", { textContent: who.name }));
1168
+ if (who.name !== author && author) head.append(el("span", { className: "clogin", textContent: author }));
1169
+ if (badge) head.append(el("span", { className: "pill", textContent: badge }));
1170
+ head.append(
1171
+ el("span", { className: "cwhen", title: new Date(when).toLocaleString(), textContent: ago(when) }),
1172
+ );
1173
+ d.append(head);
1174
+ const bodyEl = el("div", { className: "cbodywrap" });
1175
+ bodyEl.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
1010
1176
  const marks = reactionRow(reactions);
1011
- if (marks) d.append(marks);
1177
+ if (marks) bodyEl.append(marks);
1178
+ d.append(bodyEl);
1012
1179
  return d;
1013
1180
  }
1014
1181
 
@@ -1156,9 +1323,22 @@ function newIssueForm(viewer) {
1156
1323
  body: body.value,
1157
1324
  labels: picked.chosen(),
1158
1325
  });
1159
- status.innerHTML =
1160
- 'created · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">open it</a>';
1161
- await refreshAll(true);
1326
+ /* The form is done once GitHub has the issue. What you sent is shown as what it now is,
1327
+ a thread in the inbox, open and selected, rather than a link out to github.com beside
1328
+ a form still holding what you typed. The filters are set so its row is on the list. */
1329
+ const number = Number(String(r.url ?? "").match(/\/issues\/(\d+)/)?.[1]);
1330
+ S.inboxOpen = number ? { repo: s.brain, number, kind: "issue" } : null;
1331
+ S.inboxState = "";
1332
+ S.inboxFilter = "";
1333
+ S.query = "";
1334
+ if (S.inboxStaff && S.inboxStaff !== s.handle) S.inboxStaff = s.handle;
1335
+ writeHash(false);
1336
+ status.textContent = "sent · opening it…";
1337
+ // It is sent either way. A refresh that fails must not read as a send that failed.
1338
+ await refreshAll(false).catch(() => {
1339
+ status.textContent = "sent · refresh to see it";
1340
+ });
1341
+ return;
1162
1342
  } catch (e) {
1163
1343
  status.textContent = e.message;
1164
1344
  status.className = "meta err";