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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4833 -2752
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +165 -48
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +236 -15
  51. package/templates/ops/agents.mjs +7 -3
  52. package/templates/ops/compose.mjs +31 -3
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +43 -4
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_inflight.md +14 -0
  57. package/templates/ops/prompts/_paths.md +2 -1
  58. package/templates/ops/prompts/daily.md +37 -9
  59. package/templates/ops/prompts/mention.md +21 -0
  60. package/templates/ops/run-record.mjs +146 -0
  61. package/templates/portal/css/base.css +167 -73
  62. package/templates/portal/css/brain.css +23 -20
  63. package/templates/portal/css/diff.css +10 -9
  64. package/templates/portal/css/graph.css +12 -7
  65. package/templates/portal/css/health.css +13 -11
  66. package/templates/portal/css/home.css +93 -0
  67. package/templates/portal/css/inbox.css +45 -25
  68. package/templates/portal/css/layout.css +90 -46
  69. package/templates/portal/css/markdown.css +36 -14
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +116 -34
  72. package/templates/portal/index.html +21 -9
  73. package/templates/portal/js/api.js +44 -4
  74. package/templates/portal/js/app.js +94 -9
  75. package/templates/portal/js/dialog.js +83 -0
  76. package/templates/portal/js/homesort.js +174 -0
  77. package/templates/portal/js/icons.js +37 -0
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/md.js +5 -2
  80. package/templates/portal/js/mdedit.js +84 -0
  81. package/templates/portal/js/readiness.js +70 -0
  82. package/templates/portal/js/refresh.js +10 -2
  83. package/templates/portal/js/state.js +11 -5
  84. package/templates/portal/js/views/app.js +24 -7
  85. package/templates/portal/js/views/brain.js +1 -1
  86. package/templates/portal/js/views/checklist.js +10 -4
  87. package/templates/portal/js/views/credential.js +84 -0
  88. package/templates/portal/js/views/graph.js +1 -1
  89. package/templates/portal/js/views/health.js +17 -4
  90. package/templates/portal/js/views/hire.js +593 -0
  91. package/templates/portal/js/views/home.js +546 -0
  92. package/templates/portal/js/views/inbox.js +226 -70
  93. package/templates/portal/js/views/org.js +46 -106
  94. package/templates/portal/js/views/orgedit.js +234 -0
  95. package/templates/portal/js/views/paste.js +87 -21
  96. package/templates/portal/js/views/prompt.js +11 -4
  97. package/templates/portal/js/views/repos.js +20 -15
  98. package/templates/portal/js/views/runonce.js +94 -0
  99. package/templates/portal/js/views/runs.js +170 -0
  100. package/templates/portal/js/views/setup.js +261 -75
  101. package/templates/portal/js/views/staff.js +170 -243
  102. package/templates/portal/js/views/todo.js +62 -0
@@ -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
  /**
@@ -104,16 +108,36 @@ export const viewInbox = (m) => inboxScreen(m, {});
104
108
  */
105
109
  export const viewPrs = (m) => inboxScreen(m, { prs: true });
106
110
 
111
+ /**
112
+ * One thread, drawn into `host` on its own: the header, its actions, the reply box and the
113
+ * conversation, with no list beside it. Home opens a card this way, in a side sheet.
114
+ *
115
+ * It is the same screen as Trackers, built off-page, with only its viewer kept. Replying,
116
+ * closing and merging repaint a list nobody can see and then reopen this thread, which is
117
+ * what they do on Trackers too.
118
+ */
119
+ export function threadInto(host, ref) {
120
+ /* Read fresh. Home can be minutes behind GitHub, and the reason to open something is usually
121
+ to read the reply that made its row change. Reading it again also brings the row up to
122
+ date, so Home is right when the sheet closes. */
123
+ const item = (S.inbox?.items ?? []).find((i) => i.repo === ref.repo && i.number === ref.number);
124
+ if (item) {
125
+ THREADS.delete(item.repo + "#" + item.number);
126
+ item.partial = true;
127
+ }
128
+ host.append(inboxScreen(el("div"), { prs: ref.kind === "pr", only: ref }));
129
+ }
130
+
107
131
  function inboxScreen(m, opts) {
108
132
  /* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
109
133
  has finished and cannot land on their own, which is the thing you are being asked about.
110
134
  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" }));
135
+ m.append(el("h1", { textContent: opts.prs ? "Pending work" : "Trackers" }));
112
136
  const sub = el("p", {
113
137
  className: "sub",
114
138
  textContent: opts.prs
115
139
  ? "Work the staff have finished and cannot land themselves."
116
- : "Everything open across the org. Pull requests are in Pending work.",
140
+ : "Every issue on each staff member's tracker and the product repos. What needs you is on Home.",
117
141
  });
118
142
  m.append(sub);
119
143
 
@@ -122,37 +146,25 @@ function inboxScreen(m, opts) {
122
146
  placeholder: "Filter by title, label, repo…",
123
147
  value: S.query,
124
148
  });
125
- const scope = el("select");
126
- scope.append(
127
- el("option", { value: "", textContent: "Everything" }),
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" })]),
131
- );
132
- scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
133
-
134
- /* Open only by default. An inbox is what is waiting on somebody, and burying that under
135
- five months of finished work would be answering a different question. */
136
- const state = el("select", { title: "Which items to list" });
137
- state.append(
138
- el("option", { value: "", textContent: "Open" }),
139
- el("option", { value: "closed", textContent: "Recently closed" }),
140
- el("option", { value: "all", textContent: "Open and closed" }),
141
- );
142
- state.value = S.inboxState;
143
-
144
- const whose = el("select", {
145
- title: "Whose work: their brain repo, anything their bot wrote, and anything addressed to them",
146
- });
147
- whose.append(
148
- el("option", { value: "", textContent: "Everyone" }),
149
- ...S.data.staff.map((s) => el("option", { value: s.handle, textContent: s.name })),
150
- );
151
- whose.value = S.inboxStaff;
149
+ /* Open or closed, as a toggle. Who and what (a staff member, what is on you, decisions)
150
+ moved to the sidebar under Inbox, where a row of dropdowns used to be. */
151
+ if (S.inboxState === "all") S.inboxState = "";
152
+ const toggle = el("div", { className: "modes inboxstate" });
153
+ for (const [value, label] of [["", "Open"], ["closed", "Closed"]]) {
154
+ const b = el("button", { className: "mode", textContent: label });
155
+ b.classList.toggle("on", (S.inboxState || "") === value);
156
+ b.onclick = () => {
157
+ S.inboxState = value;
158
+ for (const x of toggle.children) x.classList.toggle("on", x === b);
159
+ writeHash(false);
160
+ paint();
161
+ };
162
+ toggle.append(b);
163
+ }
152
164
 
153
165
  const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
154
166
  refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
155
- const controls = [search, whose, state, scope, refresh];
167
+ const controls = [search, toggle, refresh];
156
168
  // A pull request comes from a branch, so there is nothing here that could open one.
157
169
  if (!opts.prs) {
158
170
  const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
@@ -162,7 +174,7 @@ function inboxScreen(m, opts) {
162
174
  };
163
175
  controls.push(newBtn);
164
176
  }
165
- m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
177
+ m.append(el("div", { className: "row inboxbar", style: "margin-bottom:16px" }, controls));
166
178
 
167
179
  const split = el("div", { className: "split" });
168
180
  const list = el("div", { className: "tree" });
@@ -171,16 +183,18 @@ function inboxScreen(m, opts) {
171
183
  m.append(split);
172
184
 
173
185
  search.oninput = () => { S.query = search.value; writeHash(false); paint(); };
174
- scope.onchange = () => { S.inboxFilter = scope.value; writeHash(false); paint(); };
175
- whose.onchange = () => { S.inboxStaff = whose.value; writeHash(false); paint(); };
176
- state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
177
186
  refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
178
187
 
179
188
  if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
189
+ if (opts.only) return viewer;
180
190
 
181
191
  /** A `?t=` in the URL names a thread. It used to be read into the state and then never
182
192
  acted on, because only the already-loaded branch opened one. */
183
193
  function restore() {
194
+ if (opts.only) {
195
+ openThread(opts.only);
196
+ return;
197
+ }
184
198
  if (!S.inboxOpen) return;
185
199
  // The two screens share one open thread, and each holds half the org. Whatever was open on
186
200
  // the other one is not in this list, so it is dropped rather than opened beside it.
@@ -245,12 +259,22 @@ function inboxScreen(m, opts) {
245
259
  // "On you" is on any of the humans this org answers to, not only the first one.
246
260
  const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
247
261
 
248
- const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
262
+ // The sidebar's filters are the inbox's. Pending work has no control for them, so it ignores them.
263
+ const staffPick = opts.prs ? "" : S.inboxStaff;
264
+ // "Issues" is the product repos: every repo a staff member works in.
265
+ const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
266
+ const issuesOnly = staffPick === "@issues";
267
+ const unreadOnly = staffPick === "@unread";
268
+ const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
249
269
  /* The two screens partition the org rather than overlap on it. Pending work is every pull
250
270
  request; the inbox is everything else. An inbox that also listed the PRs said the same
251
271
  thing twice and made the badge beside it a number you could not act on. */
252
272
  const mine = S.inbox.items
253
- .filter((i) => belongsTo(i, whoseStaff))
273
+ /* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
274
+ on who filed or was named put an issue the CMO filed for the CTO under both of them. */
275
+ .filter((i) =>
276
+ unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
277
+ )
254
278
  .filter((i) => (i.kind === "pr") === !!opts.prs);
255
279
  const isOpen = (i) => i.state === "OPEN";
256
280
  const scoped = mine.filter(
@@ -261,8 +285,9 @@ function inboxScreen(m, opts) {
261
285
  (i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
262
286
  .toLowerCase().includes(q),
263
287
  );
264
- if (S.inboxFilter === "mine") items = items.filter(onAHuman);
265
- else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
288
+ const filter = opts.prs ? "" : S.inboxFilter;
289
+ if (filter === "mine") items = items.filter(onAHuman);
290
+ else if (filter === "decision") items = items.filter((i) => i.labels.includes("decision"));
266
291
  /* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
267
292
  old bookmark carrying it is ignored rather than honoured into an empty list. */
268
293
 
@@ -298,9 +323,15 @@ function inboxScreen(m, opts) {
298
323
  list.append(el("div", { className: "tdir err", textContent: e }));
299
324
  }
300
325
 
301
- // Newest first, flat. Grouping by repo buried a fresh comment under whichever project
302
- // happened to sort first; the project moves onto the row instead.
303
- items = items.slice().sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
326
+ // Unread first, then newest first, flat. Grouping by repo buried a fresh comment under
327
+ // whichever project happened to sort first; the project moves onto the row instead.
328
+ items = items
329
+ .slice()
330
+ .sort(
331
+ (a, b) =>
332
+ Number(Boolean(b.unread)) - Number(Boolean(a.unread)) ||
333
+ String(b.updatedAt).localeCompare(String(a.updatedAt)),
334
+ );
304
335
  if (!items.length) {
305
336
  list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
306
337
  return;
@@ -308,7 +339,7 @@ function inboxScreen(m, opts) {
308
339
 
309
340
  for (const i of items) {
310
341
  const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
311
- const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
342
+ const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
312
343
  const yours = onAHuman(i);
313
344
  const chips = i.labels
314
345
  .slice(0, 3)
@@ -335,9 +366,9 @@ function inboxScreen(m, opts) {
335
366
  : "") +
336
367
  // How much conversation is on a thread, which is most of what tells a live one from
337
368
  // 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>"
369
+ ((i.commentCount ?? i.comments.length)
370
+ ? '<span class="cc" title="' + (i.commentCount ?? i.comments.length) + ' comments">' +
371
+ iconHTML("review") + (i.commentCount ?? i.comments.length) + "</span>"
341
372
  : "") +
342
373
  chips +
343
374
  '<span class="when">' + ago(i.updatedAt) + "</span>" +
@@ -367,22 +398,63 @@ function inboxScreen(m, opts) {
367
398
  }
368
399
  const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
369
400
  if (row) markCurrent(list, row);
401
+ // Opening it is reading it: cleared here at once, and on GitHub in the background.
402
+ if (item.unread) {
403
+ const thread = item.unread.thread;
404
+ item.unread = null;
405
+ row?.classList.remove("unread");
406
+ stampCounts();
407
+ post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
408
+ }
409
+
410
+ /* The list carries no bodies or timelines (carrying them cost most of an hour's GitHub
411
+ allowance per load), so a thread is read when it is opened, and kept until GitHub says
412
+ it has changed. Reopening one costs nothing. */
413
+ if (item.partial) {
414
+ const key = item.repo + "#" + item.number;
415
+ const kept = THREADS.get(key);
416
+ const keep = { role: item.role, unread: item.unread };
417
+ if (kept && kept.updatedAt === item.updatedAt) {
418
+ Object.assign(item, kept, keep, { partial: false });
419
+ } else {
420
+ viewer.dataset.want = key;
421
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
422
+ getThread(item.repo, item.number, item.kind)
423
+ .then((fresh) => {
424
+ if (!fresh || fresh.error) throw new Error(fresh?.error ?? "could not read it");
425
+ THREADS.set(key, fresh);
426
+ Object.assign(item, fresh, keep, { partial: false });
427
+ })
428
+ .catch((e) => {
429
+ item.partial = false;
430
+ item.loadError = String(e.message || e);
431
+ })
432
+ .finally(() => {
433
+ // Somebody may have opened another thread while this one was loading.
434
+ if (viewer.dataset.want === key) openThread(ref);
435
+ });
436
+ return;
437
+ }
438
+ }
370
439
 
371
440
  const head = el("div", { className: "thead" });
372
441
  head.innerHTML =
373
442
  '<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
374
443
  ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
375
- " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
444
+ " · " + esc(item.author) + " · " + ago(item.updatedAt) +
445
+ // The labels sit on this line, after the time, rather than taking a line of their own.
446
+ (item.labels.length
447
+ ? '<span class="tmetalabels">' +
448
+ item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
449
+ "</span>"
450
+ : "") +
451
+ "</div>" +
376
452
  "<h3>" + esc(item.title) + "</h3>" +
377
453
  (item.mergeable === "CONFLICTING"
378
454
  ? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
379
455
  "until someone rebases or merges the base into it.</p>"
380
456
  : "") +
381
- (item.labels.length
382
- ? '<div class="row tlabels">' +
383
- item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
384
- "</div>"
385
- : "");
457
+ "";
386
458
  head.append(threadActions(item));
387
459
  delete viewer.dataset.wait;
388
460
  viewer.replaceChildren(head);
@@ -393,6 +465,11 @@ function inboxScreen(m, opts) {
393
465
  for a wrapper that would only ever hold one thing. */
394
466
  const pane = item.kind === "pr" ? el("div") : viewer;
395
467
  if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
468
+ if (item.loadError) {
469
+ pane.append(el("p", { className: "err", textContent: "Couldn't load the conversation: " + item.loadError }));
470
+ delete item.loadError;
471
+ item.partial = true; // so opening it again tries again
472
+ }
396
473
  conversation(pane, item);
397
474
  viewer.scrollTop = 0;
398
475
  }
@@ -575,10 +652,11 @@ function inboxScreen(m, opts) {
575
652
  status.textContent = msg ?? "";
576
653
  status.className = "meta";
577
654
  };
655
+ // busy(false) clears the status line, so it goes first: the other way round wiped the
656
+ // error as it was written, and a refused merge looked like nothing happening at all.
578
657
  const failed = (e) => {
579
- status.textContent = e.message;
580
- status.className = "meta err";
581
658
  busy(false);
659
+ status.textContent = e.message;
582
660
  status.className = "meta err";
583
661
  };
584
662
 
@@ -673,7 +751,20 @@ function inboxScreen(m, opts) {
673
751
  body: body || undefined,
674
752
  });
675
753
  DRAFTS.delete(key);
676
- await refreshAll(false);
754
+ /* What changed is known, so it is changed here rather than re-reading every repo,
755
+ which redrew the whole inbox for one issue. The next refresh confirms it. */
756
+ const me = humansOf()[0]?.github ?? "you";
757
+ const now = new Date().toISOString();
758
+ item.events = [
759
+ ...(item.events ?? []),
760
+ ...(body ? [{ type: "comment", actor: me, createdAt: now, body }] : []),
761
+ { type: closing ? "closed" : "reopened", actor: me, createdAt: now },
762
+ ];
763
+ item.state = closing ? "CLOSED" : "OPEN";
764
+ item.updatedAt = now;
765
+ stampCounts();
766
+ paint();
767
+ openThread({ repo: item.repo, number: item.number, kind: item.kind });
677
768
  } catch (e) { failed(e); }
678
769
  };
679
770
  buttons.push(closeBtn);
@@ -693,11 +784,20 @@ function inboxScreen(m, opts) {
693
784
  merge.title = "This branch conflicts with its base";
694
785
  }
695
786
  merge.onclick = async () => {
696
- if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
787
+ const yes = await askYes({
788
+ title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
789
+ confirm: "Merge",
790
+ });
791
+ if (!yes) return;
697
792
  busy(true, "merging…");
698
793
  try {
699
794
  await post({ action: "merge", repo: item.repo, number: item.number });
700
- await refreshAll(false);
795
+ // As with closing: the change is known, so it is made here rather than re-reading every repo.
796
+ item.state = "MERGED";
797
+ item.updatedAt = new Date().toISOString();
798
+ stampCounts();
799
+ paint();
800
+ openThread({ repo: item.repo, number: item.number, kind: item.kind });
701
801
  } catch (e) { failed(e); }
702
802
  };
703
803
  buttons.push(merge);
@@ -727,7 +827,12 @@ function inboxScreen(m, opts) {
727
827
  const at = S.inbox.items.findIndex(
728
828
  (i) => i.repo === item.repo && i.number === item.number,
729
829
  );
730
- if (at >= 0) S.inbox.items[at] = { ...S.inbox.items[at], ...fresh };
830
+ if (at >= 0) {
831
+ const old = S.inbox.items[at];
832
+ THREADS.set(item.repo + "#" + item.number, fresh);
833
+ // A single thread does not know which part of the org it is in, or whether it is unread.
834
+ S.inbox.items[at] = { ...old, ...fresh, role: old.role, unread: old.unread, partial: false };
835
+ }
731
836
  }
732
837
  } catch {
733
838
  /* the comment posted; a stale pane is not worth an error */
@@ -763,6 +868,19 @@ function inboxScreen(m, opts) {
763
868
  * you are still typing and the button has to stay as it was; once it closes there is a request
764
869
  * in flight against GitHub that can take seconds, and that is what needs saying.
765
870
  */
871
+ /**
872
+ * Who a reply is for, by default: the staff member whose tracker the issue is on, or whose App
873
+ * opened it. They are only woken by an @mention, so a reply without one reached nobody.
874
+ */
875
+ export function addressee(item) {
876
+ const staff = S.data?.staff ?? [];
877
+ const s =
878
+ staff.find((x) => x.brain === item.repo) ??
879
+ staff.find((x) => (x.soloBots ?? []).includes(item.author));
880
+ if (!s) return "";
881
+ return (s.mention || "@" + s.handle) + " ";
882
+ }
883
+
766
884
  async function openReply(item, anchor, onSend) {
767
885
  const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
768
886
  let deaf = null;
@@ -771,7 +889,7 @@ async function openReply(item, anchor, onSend) {
771
889
  title: "Reply to " + item.repo + " #" + item.number,
772
890
  hint: anchor ? anchor.path : item.title,
773
891
  confirm: "Comment",
774
- value: DRAFTS.get(key) ?? "",
892
+ value: DRAFTS.get(key) ?? addressee(item),
775
893
  /* It goes out through whoever's `gh` is signed in here, which is only knowably one person
776
894
  when the org has one. With two it would be a guess, and a wrong name on a reply box is
777
895
  worse than no name. */
@@ -782,6 +900,10 @@ async function openReply(item, anchor, onSend) {
782
900
  else DRAFTS.delete(key);
783
901
  };
784
902
  deaf = deafNote(ta, item, anchor);
903
+ // Typing starts after the @mention, not in front of it.
904
+ setTimeout(() => {
905
+ ta.selectionStart = ta.selectionEnd = ta.value.length;
906
+ }, 0);
785
907
  return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
786
908
  },
787
909
  });
@@ -1050,15 +1172,36 @@ function xref(source, item, openThread) {
1050
1172
  return b;
1051
1173
  }
1052
1174
 
1175
+ /* Who wrote something, as a person would say it: a staff member by their role, with their
1176
+ icon; one of the humans by name; anyone else by their login. */
1177
+ function whoWrote(login) {
1178
+ const s = (S.data?.staff ?? []).find((x) => (x.soloBots ?? []).includes(login));
1179
+ if (s) return { name: s.name, glyph: staffIcon(s), kind: "staff" };
1180
+ if (isHuman(login)) {
1181
+ const h = humansOf().find((x) => x.github === login);
1182
+ return { name: h?.name ?? login, glyph: "person", kind: "human" };
1183
+ }
1184
+ return { name: login || "?", glyph: "person", kind: "other" };
1185
+ }
1186
+
1187
+ /* A comment is a card: a header strip saying who and when, then what they said. As text
1188
+ between thin rules, it was hard to see where one message ended and the next began. */
1053
1189
  function comment({ author, when, body, first, repo, badge, reactions }) {
1054
- const d = el("div", { className: "cmt" + (first ? " first" : "") });
1055
- const meta = el("div", { className: "meta" });
1056
- meta.append((author || "?") + " · " + new Date(when).toLocaleString());
1057
- if (badge) meta.append(el("span", { className: "pill", style: "margin-left:8px", textContent: badge }));
1058
- d.append(meta);
1059
- d.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
1190
+ const who = whoWrote(author);
1191
+ const d = el("div", { className: "cmt " + who.kind + (first ? " first" : "") });
1192
+ const head = el("div", { className: "chead" });
1193
+ head.append(icon(who.glyph, "ic"), el("b", { textContent: who.name }));
1194
+ if (who.name !== author && author) head.append(el("span", { className: "clogin", textContent: author }));
1195
+ if (badge) head.append(el("span", { className: "pill", textContent: badge }));
1196
+ head.append(
1197
+ el("span", { className: "cwhen", title: new Date(when).toLocaleString(), textContent: ago(when) }),
1198
+ );
1199
+ d.append(head);
1200
+ const bodyEl = el("div", { className: "cbodywrap" });
1201
+ bodyEl.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
1060
1202
  const marks = reactionRow(reactions);
1061
- if (marks) d.append(marks);
1203
+ if (marks) bodyEl.append(marks);
1204
+ d.append(bodyEl);
1062
1205
  return d;
1063
1206
  }
1064
1207
 
@@ -1206,9 +1349,22 @@ function newIssueForm(viewer) {
1206
1349
  body: body.value,
1207
1350
  labels: picked.chosen(),
1208
1351
  });
1209
- status.innerHTML =
1210
- 'created · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">open it</a>';
1211
- await refreshAll(true);
1352
+ /* The form is done once GitHub has the issue. What you sent is shown as what it now is,
1353
+ a thread in the inbox, open and selected, rather than a link out to github.com beside
1354
+ a form still holding what you typed. The filters are set so its row is on the list. */
1355
+ const number = Number(String(r.url ?? "").match(/\/issues\/(\d+)/)?.[1]);
1356
+ S.inboxOpen = number ? { repo: s.brain, number, kind: "issue" } : null;
1357
+ S.inboxState = "";
1358
+ S.inboxFilter = "";
1359
+ S.query = "";
1360
+ if (S.inboxStaff && S.inboxStaff !== s.handle) S.inboxStaff = s.handle;
1361
+ writeHash(false);
1362
+ status.textContent = "sent · opening it…";
1363
+ // It is sent either way. A refresh that fails must not read as a send that failed.
1364
+ await refreshAll(false).catch(() => {
1365
+ status.textContent = "sent · refresh to see it";
1366
+ });
1367
+ return;
1212
1368
  } catch (e) {
1213
1369
  status.textContent = e.message;
1214
1370
  status.className = "meta err";