@nanocollective/roster 0.1.0-alpha.44 → 0.1.0-alpha.45

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.
package/docs/portal.md CHANGED
@@ -87,9 +87,9 @@ Where the portal opens. An Ask box at the top, then these, in order.
87
87
  - **Latest reports.** Each staff member's run report from the last day: the three lines they
88
88
  post on their status issue at the end of a daily run. Nothing shows until there is one.
89
89
  - **Needs you.** Every ask a staff member has put on you, and every pull request ready to
90
- merge, oldest first. Each card has its action on it:
91
- - a `decision` has an answer box, and **Go with the default** when it carries one. The
92
- default and its date are shown, and turn amber once the date has passed.
90
+ merge, oldest first. Each card has one action on its right:
91
+ - a `decision` has **Reply**, and **Go with the default** when it carries one. The default
92
+ and its date are shown, and turn amber once the date has passed.
93
93
  - a `review` has **Approve**.
94
94
  - a `chore` has **Done**, which closes it.
95
95
  - a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
@@ -106,10 +106,14 @@ Where the portal opens. An Ask box at the top, then these, in order.
106
106
  - **Closed today.** What the staff closed today, with the line they closed it with, and
107
107
  **Reopen** beside each. Staff close finished issues themselves, so this is where you check.
108
108
 
109
+ **Every item opens in a side sheet.** Click anywhere on a card or a row and its thread opens
110
+ on the right: the conversation, Reply, Close or Reopen, Merge for a pull request, and Open in
111
+ GitHub. Escape or the × closes it, and Home repaints where you were.
112
+
109
113
  **Nothing is missed.** Every open issue and pull request has exactly one place: on Home, or on
110
114
  the staff member's own tracker (their status issue, a peer's ask, their own work), or elsewhere
111
115
  (draft pull requests, contributor issues). The line at the bottom counts the last two, with a
112
- link to Trackers. Clicking any title opens its thread there.
116
+ link to Trackers.
113
117
 
114
118
  ## Trackers
115
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.44",
3
+ "version": "0.1.0-alpha.45",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -155,6 +155,7 @@ svg.ic{width:var(--ic,15px);height:var(--ic,15px);display:block;flex:none}
155
155
  .chip.hot{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
156
156
  .chip.warm{color:var(--warn);background:color-mix(in srgb,var(--warn) 13%,transparent)}
157
157
  .chip.cool{color:var(--ruled);background:color-mix(in srgb,var(--ruled) 12%,transparent)}
158
+ .chip.ok{color:var(--accent);background:var(--accent-soft)}
158
159
  .pill{font:11px var(--mono);color:var(--ink-dim);border:1px solid transparent;
159
160
  background:color-mix(in srgb,var(--ink) 4%,transparent);
160
161
  border-radius:999px;padding:2px 9px;font-variant-numeric:tabular-nums}
@@ -284,6 +285,16 @@ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);bac
284
285
  dialog.sheet[open]{animation:dlgin .18s var(--ease)}
285
286
  dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
286
287
  .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
288
+
289
+ /* The side sheet: a thread opened from Home, full height on the right. */
290
+ dialog.sheet.side{margin:0 0 0 auto;height:100vh;max-height:100vh;width:min(780px,100vw);
291
+ border-radius:0;border-width:0 0 0 1px}
292
+ dialog.sheet.side[open]{animation:sidein .22s var(--ease)}
293
+ dialog.sheet.side .sheetbody{max-height:100vh;height:100%;box-sizing:border-box;padding:18px 22px 40px}
294
+ @keyframes sidein{from{transform:translateX(24px);opacity:0}}
295
+ @media (prefers-reduced-motion:reduce){dialog.sheet.side[open]{animation:none}}
296
+ .sidebar-x{position:sticky;top:0;display:flex;justify-content:flex-end;margin:-6px -6px 4px;z-index:1}
297
+ dialog.sheet.side .viewer{border:0;box-shadow:none;padding:0;min-height:0;background:none}
287
298
  .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
288
299
  .sheetloading{display:grid;place-items:center;min-height:260px}
289
300
  .spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
@@ -15,31 +15,54 @@
15
15
  .homewho{font:500 12.5px var(--sans);color:var(--ink);background:var(--surface-2);border:0;
16
16
  border-radius:8px;padding:5px 8px}
17
17
 
18
- .homecard{padding:14px 16px}
18
+ .homecard{padding:14px 16px;cursor:pointer;display:grid;grid-template-columns:1fr auto;
19
+ align-items:center;gap:10px 18px}
20
+ .homeabout{min-width:0}
21
+ .homecard:hover{border-color:var(--border-strong)}
22
+ .homecard:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
19
23
  .homehead{display:flex;align-items:baseline;gap:8px}
20
24
  .homehead .chip{flex:none}
21
25
  .homeopen{all:unset;cursor:pointer;font:600 14px/1.35 var(--sans);color:var(--ink);letter-spacing:-.01em}
22
26
  .homeopen:hover{text-decoration:underline}
23
27
  .homeopen:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
24
- .homecard > .meta{margin-top:4px}
28
+ .homeabout > .meta{margin-top:4px}
25
29
  .homedue{margin:8px 0 0;font-size:12.5px;color:var(--ink-dim)}
26
30
  .homedue.late{color:var(--warn)}
27
- .homeacts{margin-top:10px}
31
+ .homeacts{justify-content:flex-end;flex-wrap:nowrap}
32
+ .homeacts .meta{white-space:normal;text-align:right;max-width:220px}
33
+ @media (max-width:640px){
34
+ .homecard{grid-template-columns:1fr}
35
+ .homeacts{justify-content:flex-start;flex-wrap:wrap}
36
+ .homeacts .meta{text-align:left}
37
+ }
28
38
 
29
- .homeworking{display:flex;flex-direction:column;gap:10px;padding:12px 16px}
30
- .homerun{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:13px}
31
- .homewho2{display:inline-flex;align-items:center;gap:6px;min-width:190px}
32
- .homewho2 .ic{--ic:14px;color:var(--ink-dim)}
33
- .homelive{color:var(--accent);text-decoration:none;font-size:12.5px}
34
- .homelive::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
35
- background:var(--accent);margin-right:6px;animation:homepulse 1.6s var(--ease) infinite}
36
- .homelive:hover{text-decoration:underline}
37
- @keyframes homepulse{50%{opacity:.35}}
38
- @media (prefers-reduced-motion:reduce){.homelive::before{animation:none}}
39
+ .homeworking{display:flex;flex-direction:column;padding:4px 16px}
40
+ .homerun{display:grid;grid-template-columns:minmax(150px,220px) auto 1fr auto;align-items:center;
41
+ gap:12px;padding:12px 0;font-size:13.5px}
42
+ .homerun + .homerun{border-top:1px solid var(--line)}
43
+ .homewho2{display:inline-flex;align-items:center;gap:8px;min-width:0}
44
+ .homewho2 b{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
45
+ .homewho2 .ic{--ic:15px;color:var(--ink-dim);flex:none}
46
+ .homestate{justify-self:start}
47
+ .homestate.ok::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;
48
+ margin-right:6px;animation:homepulse 1.6s var(--ease) infinite}
49
+ @keyframes homepulse{50%{opacity:.3}}
50
+ @media (prefers-reduced-motion:reduce){.homestate.ok::before{animation:none}}
51
+ .homerunwhat{color:var(--ink);line-height:1.45;min-width:0}
52
+ .homerunsub{color:var(--ink-dim);font-size:12.5px;margin-top:2px}
53
+ .homerunlog .ghbtn{min-height:28px;padding:3px 12px}
54
+ @media (max-width:640px){
55
+ .homerun{grid-template-columns:1fr auto;grid-template-areas:"who state" "what what" "log log"}
56
+ .homewho2{grid-area:who}.homestate{grid-area:state;justify-self:end}
57
+ .homerunwhat{grid-area:what}.homerunlog{grid-area:log}
58
+ }
39
59
 
40
60
  .homerow{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding:8px 2px;
41
61
  border-bottom:1px solid var(--line);font-size:13px}
42
62
  .homerow .homeopen{font-weight:500;font-size:13px}
63
+ .homeclick{cursor:pointer;border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px}
64
+ .homeclick:hover{background:var(--hover)}
65
+ .homeclick:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
43
66
  .homeclosed{display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 14px}
44
67
  .homeclosed .meta{margin-top:3px;line-height:1.5;font-family:var(--sans);font-size:12px}
45
68
  .homerest{margin-top:26px}
@@ -52,6 +75,5 @@
52
75
  @media (max-width:640px){.homereport{grid-template-columns:1fr;gap:4px}}
53
76
 
54
77
  @media (max-width:640px){
55
- .homewho2{min-width:0}
56
78
  .homehead{flex-wrap:wrap}
57
79
  }
@@ -167,10 +167,11 @@ export function askYes({ title, hint, confirm = "Continue" }) {
167
167
  * @returns {{set: (node: Node) => void, close: () => void, isOpen: () => boolean} | null}
168
168
  * null where there is no dialog element (the test shim), and the caller draws inline instead.
169
169
  */
170
- export function sheet({ node, onClose }) {
170
+ export function sheet({ node, onClose, side = false }) {
171
171
  const box = document.createElement("dialog");
172
172
  if (typeof box.showModal !== "function") return null;
173
- box.className = "sheet";
173
+ // `side` slides in from the right at full height, for reading a thread beside the page.
174
+ box.className = side ? "sheet side" : "sheet";
174
175
  const body = el("div", { className: "sheetbody" });
175
176
  body.append(node);
176
177
  box.append(body);
@@ -1,15 +1,17 @@
1
1
  /* Home: what needs you, who is working, what you asked for, and what was closed today.
2
2
  *
3
3
  * Built from the inbox list and /api/live, sorted by homesort.js, which is where the rule that
4
- * every open item has exactly one place lives. Clicking an item opens its thread on the
5
- * Trackers screen, which is the same thread view the inbox always had. */
4
+ * every open item has exactly one place lives. Clicking an item opens its thread in a side
5
+ * sheet, the same thread view Trackers has, with the reply box in it. */
6
6
 
7
7
  import { getLive, post } from "../api.js";
8
+ import { sheet } from "../dialog.js";
8
9
  import { ago, el, skeleton } from "../dom.js";
9
10
  import { daysUntil, sortHome } from "../homesort.js";
10
- import { icon, staffIcon } from "../icons.js";
11
+ import { icon, iconHTML, staffIcon } from "../icons.js";
11
12
  import { ensureInbox, stampCounts } from "../refresh.js";
12
13
  import { go, render } from "../router.js";
14
+ import { threadInto } from "./inbox.js";
13
15
  import { humansOf, S } from "../state.js";
14
16
 
15
17
  const KIND_LABEL = {
@@ -143,26 +145,37 @@ function titleOf(said) {
143
145
 
144
146
  function needCard(e) {
145
147
  const { item, kind } = e;
146
- const card = el("div", { className: "card homecard" });
147
- card.append(
148
+ /* The whole card opens the thread; the one quick action on it does not. Answering in words
149
+ happens in the thread, where what was asked is in front of you. */
150
+ const card = el("div", { className: "card homecard", tabIndex: 0, role: "button" });
151
+ card.onclick = (ev) => {
152
+ if (!ev.target.closest("button, a, textarea")) openThread(item);
153
+ };
154
+ card.onkeydown = (ev) => {
155
+ if (ev.target === card && (ev.key === "Enter" || ev.key === " ")) {
156
+ ev.preventDefault();
157
+ openThread(item);
158
+ }
159
+ };
160
+ // What it is on the left, what you can do about it on the right.
161
+ const about = el("div", { className: "homeabout" }, [
148
162
  el("div", { className: "homehead" }, [
149
163
  el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
150
164
  openLink(item),
151
165
  ]),
152
166
  el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
153
- );
167
+ ]);
168
+ card.append(about);
154
169
 
155
- if (kind === "decision" && item.due) card.append(dueLine(item.due));
156
- if (kind === "merge") card.append(checksLine(item));
170
+ if (kind === "decision" && item.due) about.append(dueLine(item.due));
171
+ if (kind === "merge") about.append(checksLine(item));
157
172
 
173
+ // The note comes first so that what happened sits just left of the button that did it.
158
174
  const note = el("span", { className: "meta" });
159
- const actions = el("div", { className: "row homeacts" });
175
+ const actions = el("div", { className: "row homeacts" }, [note]);
160
176
 
161
177
  if (kind === "decision" || kind === "ask") {
162
- const box = el("textarea", { className: "homeanswer", rows: 2, placeholder: "Your answer" });
163
- const send = button("Send", true, () => say(e, box.value, note, actions));
164
- card.append(box);
165
- actions.append(send);
178
+ actions.append(button("Reply", true, () => openThread(item)));
166
179
  if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
167
180
  } else if (kind === "review") {
168
181
  actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
@@ -174,7 +187,6 @@ function needCard(e) {
174
187
  merge.disabled = blocked || item.checks === "pending";
175
188
  actions.append(merge);
176
189
  }
177
- actions.append(button("Open", false, () => openThread(item)), note);
178
190
  card.append(actions);
179
191
  return card;
180
192
  }
@@ -248,45 +260,69 @@ function whatRun(r) {
248
260
  function working(staff) {
249
261
  const box = el("div", { className: "card homeworking" });
250
262
  if (!S.live) {
251
- box.append(el("p", { className: "meta", textContent: "Asking GitHub…" }));
263
+ box.append(el("p", { className: "homerunwhat", textContent: "Asking GitHub…" }));
252
264
  return box;
253
265
  }
254
266
  for (const s of staff) {
255
267
  const l = (S.live.staff ?? []).find((x) => x.handle === s.handle);
256
- const row = el("div", { className: "homerun" });
257
- row.append(el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]));
268
+ const runs = l && !l.error ? (l.running ?? []) : [];
269
+ const busy = runs.length > 0;
270
+ const last = l?.finished?.[0];
271
+
272
+ const what = el("div", { className: "homerunwhat" });
273
+ let log = null;
258
274
  if (!l || l.error) {
259
- row.append(el("span", { className: "meta err", textContent: l?.error ?? "not read" }));
260
- } else if (l.running?.length) {
261
- for (const r of l.running) row.append(runLink(s, r, true));
275
+ what.append(el("span", { className: "err", textContent: l?.error ?? "Could not read their runs." }));
276
+ } else if (busy) {
277
+ for (const r of runs) {
278
+ what.append(el("div", { textContent: `${cap(whatRun(r))} · ${minutes(r.createdAt)} so far` }));
279
+ }
280
+ log = runs[0].url;
281
+ } else if (last) {
282
+ what.append(
283
+ el("div", { textContent: `${cap(whatRun(last))}, ${outcome(last)} ${ago(last.updatedAt)}` }),
284
+ );
285
+ log = last.url;
262
286
  } else {
263
- const last = l.finished?.[0];
264
- row.append(
265
- el("span", {
266
- className: "meta",
267
- textContent: last ? `Idle. Last run: ${whatRun(last)}, ${outcome(last)} ${ago(last.updatedAt)}.` : "Idle.",
287
+ what.append(el("div", { textContent: "No runs in the last few hours." }));
288
+ }
289
+ if (l && !l.error && l.automatic) {
290
+ what.append(
291
+ el("div", {
292
+ className: "homerunsub",
293
+ textContent: `${l.automatic} of ${l.limit} runs today that nobody asked for`,
268
294
  }),
269
295
  );
270
- if (last) row.append(el("a", { className: "meta", href: last.url, target: "_blank", rel: "noopener", textContent: "log" }));
271
296
  }
272
- if (l && !l.error && l.automatic) {
273
- row.append(el("span", { className: "pill", title: "Runs today that nobody asked for", textContent: `${l.automatic}/${l.limit} automatic` }));
297
+
298
+ const state = el("span", {
299
+ className: "chip homestate " + (busy ? "ok" : ""),
300
+ textContent: busy ? (runs[0].status === "queued" ? "Queued" : "Working") : "Idle",
301
+ });
302
+ const actions = el("div", { className: "homerunlog" });
303
+ if (log) {
304
+ actions.append(
305
+ el("a", { className: "ghbtn", href: log, target: "_blank", rel: "noopener", textContent: "Log" }),
306
+ );
274
307
  }
275
- box.append(row);
308
+
309
+ box.append(
310
+ el("div", { className: "homerun" + (busy ? " busy" : "") }, [
311
+ el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]),
312
+ state,
313
+ what,
314
+ actions,
315
+ ]),
316
+ );
276
317
  }
277
318
  return box;
278
319
  }
279
320
 
280
- function runLink(s, r, live) {
281
- const what = whatRun(r);
282
- const mins = Math.max(1, Math.round((Date.now() - new Date(r.createdAt).getTime()) / 60000));
283
- return el("a", {
284
- className: "homelive",
285
- href: r.url,
286
- target: "_blank",
287
- rel: "noopener",
288
- textContent: (live && r.status === "queued" ? "Queued: " : "Working: ") + what + ` · ${mins} min · log`,
289
- });
321
+ const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
322
+
323
+ function minutes(since) {
324
+ const m = Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / 60000));
325
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
290
326
  }
291
327
 
292
328
  function outcome(r) {
@@ -297,8 +333,10 @@ function poll() {
297
333
  clearTimeout(timer);
298
334
  getLive()
299
335
  .then((data) => {
336
+ // Only a change repaints: a poll every few seconds must not move the page under you.
337
+ const changed = JSON.stringify(data.staff) !== JSON.stringify(S.live?.staff);
300
338
  S.live = data;
301
- if (S.view === "home") render();
339
+ if (changed && S.view === "home" && !document.querySelector("dialog[open]")) repaintInPlace();
302
340
  })
303
341
  .catch(() => {})
304
342
  .finally(() => {
@@ -313,17 +351,32 @@ function poll() {
313
351
  const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
314
352
 
315
353
  function requestRow(e) {
316
- return el("div", { className: "homerow" }, [
354
+ return clickable(e.item, "homerow", [
317
355
  el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
318
356
  openLink(e.item),
319
357
  el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
320
358
  ]);
321
359
  }
322
360
 
361
+ /** A row that opens its thread from anywhere on it, except a button inside it. */
362
+ function clickable(item, className, kids) {
363
+ const row = el("div", { className: className + " homeclick", tabIndex: 0, role: "button" }, kids);
364
+ row.onclick = (ev) => {
365
+ if (!ev.target.closest("button, a")) openThread(item);
366
+ };
367
+ row.onkeydown = (ev) => {
368
+ if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
369
+ ev.preventDefault();
370
+ openThread(item);
371
+ }
372
+ };
373
+ return row;
374
+ }
375
+
323
376
  function closedRow(e) {
324
377
  const note = el("span", { className: "meta" });
325
378
  const reopen = button("Reopen", false, () => act(note, row, { action: "reopen", repo: e.item.repo, number: e.item.number }, "Reopened."));
326
- const row = el("div", { className: "homerow homeclosed" }, [
379
+ const row = clickable(e.item, "homerow homeclosed", [
327
380
  el("div", {}, [
328
381
  openLink(e.item),
329
382
  el("div", { className: "meta", textContent: whereOf(e) + " · " + (e.reason || "No reason given.") }),
@@ -362,13 +415,33 @@ function openLink(item) {
362
415
  return b;
363
416
  }
364
417
 
418
+ /** Repaint Home where you were: the sheet may have changed what is on it, not where you are. */
419
+ function repaintInPlace() {
420
+ const main = document.querySelector("#main");
421
+ const at = { win: window.scrollY, main: main?.scrollTop ?? 0 };
422
+ render();
423
+ window.scrollTo(0, at.win);
424
+ if (main) main.scrollTop = at.main;
425
+ }
426
+
427
+ /**
428
+ * The thread in a side sheet: its header, actions, reply box and conversation. Closing it
429
+ * repaints Home, since a reply or a close there changes what belongs where. Where there is no
430
+ * <dialog> (the test shim), it falls back to the thread on Trackers.
431
+ */
365
432
  function openThread(item) {
366
- go({
367
- view: item.kind === "pr" ? "prs" : "inbox",
368
- inboxStaff: "",
369
- inboxFilter: "",
370
- inboxOpen: { repo: item.repo, number: item.number, kind: item.kind },
371
- });
433
+ const ref = { repo: item.repo, number: item.number, kind: item.kind };
434
+ const body = el("div");
435
+ const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
436
+ x.innerHTML = iconHTML("close");
437
+ body.append(el("div", { className: "sidebar-x" }, [x]));
438
+ threadInto(body, ref);
439
+ const box = sheet({ node: body, side: true, onClose: () => S.view === "home" && repaintInPlace() });
440
+ if (!box) {
441
+ go({ view: item.kind === "pr" ? "prs" : "inbox", inboxStaff: "", inboxFilter: "", inboxOpen: ref });
442
+ return;
443
+ }
444
+ x.onclick = () => box.close();
372
445
  }
373
446
 
374
447
  function whereOf(e) {
@@ -385,7 +458,7 @@ function button(label, primary, onclick) {
385
458
  /** After a write: re-read the inbox, and look for the run it starts. */
386
459
  function refreshSoon() {
387
460
  ensureInbox(true)
388
- .then(() => S.view === "home" && render())
461
+ .then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
389
462
  .catch(() => {});
390
463
  setTimeout(poll, 3_000);
391
464
  }
@@ -108,6 +108,18 @@ export const viewInbox = (m) => inboxScreen(m, {});
108
108
  */
109
109
  export const viewPrs = (m) => inboxScreen(m, { prs: true });
110
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
+ host.append(inboxScreen(el("div"), { prs: ref.kind === "pr", only: ref }));
121
+ }
122
+
111
123
  function inboxScreen(m, opts) {
112
124
  /* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
113
125
  has finished and cannot land on their own, which is the thing you are being asked about.
@@ -166,10 +178,15 @@ function inboxScreen(m, opts) {
166
178
  refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
167
179
 
168
180
  if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
181
+ if (opts.only) return viewer;
169
182
 
170
183
  /** A `?t=` in the URL names a thread. It used to be read into the state and then never
171
184
  acted on, because only the already-loaded branch opened one. */
172
185
  function restore() {
186
+ if (opts.only) {
187
+ openThread(opts.only);
188
+ return;
189
+ }
173
190
  if (!S.inboxOpen) return;
174
191
  // The two screens share one open thread, and each holds half the org. Whatever was open on
175
192
  // the other one is not in this list, so it is dropped rather than opened beside it.