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

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,15 +87,19 @@ 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.
96
96
 
97
97
  Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
98
98
  When nothing is waiting, it says so.
99
+ - **Unread.** Anything on a staff member's own tracker, or elsewhere, with activity you have not
100
+ seen: a peer's ask, a status issue, their own work. Unread comes from your GitHub
101
+ notifications, as on Trackers. Cards and rows anywhere on Home carry the same blue mark, and
102
+ opening one marks it read here and on GitHub.
99
103
  - **Working now.** Each staff member: what they are running and what started it (the daily run,
100
104
  a follow-on, answering an issue, a peer's ask), for how long, with a link to the log. When
101
105
  they are idle, how their last run ended. Peer and follow-on runs today are counted against
@@ -106,10 +110,14 @@ Where the portal opens. An Ask box at the top, then these, in order.
106
110
  - **Closed today.** What the staff closed today, with the line they closed it with, and
107
111
  **Reopen** beside each. Staff close finished issues themselves, so this is where you check.
108
112
 
113
+ **Every item opens in a side sheet.** Click anywhere on a card or a row and its thread opens
114
+ on the right: the conversation, Reply, Close or Reopen, Merge for a pull request, and Open in
115
+ GitHub. Escape or the × closes it, and Home repaints where you were.
116
+
109
117
  **Nothing is missed.** Every open issue and pull request has exactly one place: on Home, or on
110
118
  the staff member's own tracker (their status issue, a peer's ask, their own work), or elsewhere
111
119
  (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.
120
+ link to Trackers.
113
121
 
114
122
  ## Trackers
115
123
 
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.46",
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}
@@ -282,8 +283,28 @@ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);bac
282
283
  color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
283
284
  box-shadow:var(--shadow-lg)}
284
285
  dialog.sheet[open]{animation:dlgin .18s var(--ease)}
286
+ /* Pinned to the window. The graph's own `.sheet` panel is position:absolute, and that rule
287
+ reached this dialog too, so one opened on a scrolled page sat at the top of the document,
288
+ half off the screen. */
289
+ dialog.sheet{position:fixed}
285
290
  dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
286
291
  .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
292
+
293
+ /* The side sheet: a thread opened from Home, full height on the right. */
294
+ dialog.sheet.side{margin:0 0 0 auto;height:100vh;max-height:100vh;width:min(780px,100vw);
295
+ border-radius:0;border-width:0 0 0 1px}
296
+ dialog.sheet.side[open]{animation:sidein .22s var(--ease)}
297
+ dialog.sheet.side .sheetbody{max-height:100vh;height:100%;box-sizing:border-box;padding:18px 22px 40px}
298
+ @keyframes sidein{from{transform:translateX(24px);opacity:0}}
299
+ @media (prefers-reduced-motion:reduce){dialog.sheet.side[open]{animation:none}}
300
+ .sidebar-x{position:sticky;top:0;display:flex;justify-content:flex-end;margin:-6px -6px 4px;z-index:1}
301
+ dialog.sheet.side .viewer{border:0;box-shadow:none;padding:0;min-height:0;background:none}
302
+ /* The title and its actions stay in view while a long thread scrolls under them. The close
303
+ button floats over the header's right edge, so the header leaves room for it. */
304
+ dialog.sheet.side .thead{position:sticky;top:-18px;z-index:1;background:var(--bg);
305
+ padding-top:18px;margin-top:-18px;padding-right:44px}
306
+ dialog.sheet.side .sidebar-x{position:sticky;top:0;height:0;margin:0;z-index:2;overflow:visible}
307
+ dialog.sheet.side .sidebar-x .iconbtn{margin-top:-4px}
287
308
  .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
288
309
  .sheetloading{display:grid;place-items:center;min-height:260px}
289
310
  .spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
@@ -15,35 +15,66 @@
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}
28
-
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}}
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
+ }
38
+
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}
46
69
 
70
+ /* Activity you have not seen, as Trackers marks it: a blue edge and a bold title. Opening the
71
+ thread marks it read, here and on GitHub. */
72
+ .homecard.unread{box-shadow:inset 3px 0 0 var(--blue)}
73
+ .homerow.unread{box-shadow:inset 3px 0 0 var(--blue);background:color-mix(in srgb,var(--blue) 6%,transparent)}
74
+ .unread .homeopen{font-weight:700}
75
+ .unread .homeopen::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
76
+ background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
77
+
47
78
  .homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
48
79
  .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
49
80
  .homereport p{margin:0 0 3px;color:var(--ink-dim)}
@@ -52,6 +83,5 @@
52
83
  @media (max-width:640px){.homereport{grid-template-columns:1fr;gap:4px}}
53
84
 
54
85
  @media (max-width:640px){
55
- .homewho2{min-width:0}
56
86
  .homehead{flex-wrap:wrap}
57
87
  }
@@ -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);
@@ -7,6 +7,10 @@
7
7
  * staff a staff member's own work: their status issue, a peer's ask, their own issues
8
8
  * elsewhere draft pull requests, and product or ops issues nobody has put on a person
9
9
  *
10
+ * `unread` is not a place: it lists what in `staff` and `elsewhere` has activity you have not
11
+ * seen (from your GitHub notifications), since those two are otherwise off Home. Items in the
12
+ * other places carry their own unread mark.
13
+ *
10
14
  * Closed items only matter when a staff member closed them today: that is "Closed today", with
11
15
  * Reopen beside each, because staff now close things without asking. */
12
16
 
@@ -45,7 +49,15 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
45
49
  for (const r of l.running ?? []) if (s && r.issue) working.add(`${s.brain}#${r.issue}`);
46
50
  }
47
51
 
48
- const out = { reports: [], needs: [], requests: [], staff: [], elsewhere: [], closedToday: [] };
52
+ const out = {
53
+ reports: [],
54
+ needs: [],
55
+ requests: [],
56
+ staff: [],
57
+ elsewhere: [],
58
+ unread: [],
59
+ closedToday: [],
60
+ };
49
61
  const today = dayOf(now);
50
62
 
51
63
  for (const item of items) {
@@ -92,6 +104,9 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
92
104
  out.needs.sort(oldest);
93
105
  out.requests.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
94
106
  out.closedToday.sort((a, b) => b.item.closedAt.localeCompare(a.item.closedAt));
107
+ out.unread = [...out.staff, ...out.elsewhere]
108
+ .filter((e) => e.item.unread)
109
+ .sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
95
110
  return out;
96
111
  }
97
112
 
@@ -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 = {
@@ -71,6 +73,11 @@ export function viewHome(m) {
71
73
  if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
72
74
  else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
73
75
 
76
+ if (h.unread.length) {
77
+ page.append(section("Unread", h.unread.length));
78
+ for (const e of h.unread) page.append(unreadRow(e));
79
+ }
80
+
74
81
  page.append(section("Working now"));
75
82
  page.append(working(staff));
76
83
 
@@ -143,26 +150,41 @@ function titleOf(said) {
143
150
 
144
151
  function needCard(e) {
145
152
  const { item, kind } = e;
146
- const card = el("div", { className: "card homecard" });
147
- card.append(
153
+ /* The whole card opens the thread; the one quick action on it does not. Answering in words
154
+ happens in the thread, where what was asked is in front of you. */
155
+ const card = el("div", {
156
+ className: "card homecard" + (item.unread ? " unread" : ""),
157
+ tabIndex: 0,
158
+ role: "button",
159
+ });
160
+ card.onclick = (ev) => {
161
+ if (!ev.target.closest("button, a, textarea")) openThread(item);
162
+ };
163
+ card.onkeydown = (ev) => {
164
+ if (ev.target === card && (ev.key === "Enter" || ev.key === " ")) {
165
+ ev.preventDefault();
166
+ openThread(item);
167
+ }
168
+ };
169
+ // What it is on the left, what you can do about it on the right.
170
+ const about = el("div", { className: "homeabout" }, [
148
171
  el("div", { className: "homehead" }, [
149
172
  el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
150
173
  openLink(item),
151
174
  ]),
152
175
  el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
153
- );
176
+ ]);
177
+ card.append(about);
154
178
 
155
- if (kind === "decision" && item.due) card.append(dueLine(item.due));
156
- if (kind === "merge") card.append(checksLine(item));
179
+ if (kind === "decision" && item.due) about.append(dueLine(item.due));
180
+ if (kind === "merge") about.append(checksLine(item));
157
181
 
182
+ // The note comes first so that what happened sits just left of the button that did it.
158
183
  const note = el("span", { className: "meta" });
159
- const actions = el("div", { className: "row homeacts" });
184
+ const actions = el("div", { className: "row homeacts" }, [note]);
160
185
 
161
186
  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);
187
+ actions.append(button("Reply", true, () => openThread(item)));
166
188
  if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
167
189
  } else if (kind === "review") {
168
190
  actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
@@ -174,7 +196,6 @@ function needCard(e) {
174
196
  merge.disabled = blocked || item.checks === "pending";
175
197
  actions.append(merge);
176
198
  }
177
- actions.append(button("Open", false, () => openThread(item)), note);
178
199
  card.append(actions);
179
200
  return card;
180
201
  }
@@ -248,45 +269,69 @@ function whatRun(r) {
248
269
  function working(staff) {
249
270
  const box = el("div", { className: "card homeworking" });
250
271
  if (!S.live) {
251
- box.append(el("p", { className: "meta", textContent: "Asking GitHub…" }));
272
+ box.append(el("p", { className: "homerunwhat", textContent: "Asking GitHub…" }));
252
273
  return box;
253
274
  }
254
275
  for (const s of staff) {
255
276
  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 })]));
277
+ const runs = l && !l.error ? (l.running ?? []) : [];
278
+ const busy = runs.length > 0;
279
+ const last = l?.finished?.[0];
280
+
281
+ const what = el("div", { className: "homerunwhat" });
282
+ let log = null;
258
283
  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));
284
+ what.append(el("span", { className: "err", textContent: l?.error ?? "Could not read their runs." }));
285
+ } else if (busy) {
286
+ for (const r of runs) {
287
+ what.append(el("div", { textContent: `${cap(whatRun(r))} · ${minutes(r.createdAt)} so far` }));
288
+ }
289
+ log = runs[0].url;
290
+ } else if (last) {
291
+ what.append(
292
+ el("div", { textContent: `${cap(whatRun(last))}, ${outcome(last)} ${ago(last.updatedAt)}` }),
293
+ );
294
+ log = last.url;
262
295
  } 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.",
296
+ what.append(el("div", { textContent: "No runs in the last few hours." }));
297
+ }
298
+ if (l && !l.error && l.automatic) {
299
+ what.append(
300
+ el("div", {
301
+ className: "homerunsub",
302
+ textContent: `${l.automatic} of ${l.limit} runs today that nobody asked for`,
268
303
  }),
269
304
  );
270
- if (last) row.append(el("a", { className: "meta", href: last.url, target: "_blank", rel: "noopener", textContent: "log" }));
271
305
  }
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` }));
306
+
307
+ const state = el("span", {
308
+ className: "chip homestate " + (busy ? "ok" : ""),
309
+ textContent: busy ? (runs[0].status === "queued" ? "Queued" : "Working") : "Idle",
310
+ });
311
+ const actions = el("div", { className: "homerunlog" });
312
+ if (log) {
313
+ actions.append(
314
+ el("a", { className: "ghbtn", href: log, target: "_blank", rel: "noopener", textContent: "Log" }),
315
+ );
274
316
  }
275
- box.append(row);
317
+
318
+ box.append(
319
+ el("div", { className: "homerun" + (busy ? " busy" : "") }, [
320
+ el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]),
321
+ state,
322
+ what,
323
+ actions,
324
+ ]),
325
+ );
276
326
  }
277
327
  return box;
278
328
  }
279
329
 
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
- });
330
+ const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
331
+
332
+ function minutes(since) {
333
+ const m = Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / 60000));
334
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
290
335
  }
291
336
 
292
337
  function outcome(r) {
@@ -297,8 +342,10 @@ function poll() {
297
342
  clearTimeout(timer);
298
343
  getLive()
299
344
  .then((data) => {
345
+ // Only a change repaints: a poll every few seconds must not move the page under you.
346
+ const changed = JSON.stringify(data.staff) !== JSON.stringify(S.live?.staff);
300
347
  S.live = data;
301
- if (S.view === "home") render();
348
+ if (changed && S.view === "home" && !document.querySelector("dialog[open]")) repaintInPlace();
302
349
  })
303
350
  .catch(() => {})
304
351
  .finally(() => {
@@ -312,18 +359,55 @@ function poll() {
312
359
 
313
360
  const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
314
361
 
362
+ const WHY = {
363
+ status: "Status",
364
+ peer: "Peer ask",
365
+ own: "Their work",
366
+ draft: "Draft",
367
+ contributor: "Issue",
368
+ other: "Issue",
369
+ };
370
+
371
+ /** Something on a staff member's tracker, or elsewhere, with activity you have not seen. */
372
+ function unreadRow(e) {
373
+ return clickable(e.item, "homerow", [
374
+ el("span", { className: "chip", textContent: WHY[e.why] ?? "Issue" }),
375
+ openLink(e.item),
376
+ el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
377
+ ]);
378
+ }
379
+
315
380
  function requestRow(e) {
316
- return el("div", { className: "homerow" }, [
381
+ return clickable(e.item, "homerow", [
317
382
  el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
318
383
  openLink(e.item),
319
384
  el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
320
385
  ]);
321
386
  }
322
387
 
388
+ /** A row that opens its thread from anywhere on it, except a button inside it. */
389
+ function clickable(item, className, kids) {
390
+ const row = el(
391
+ "div",
392
+ { className: className + " homeclick" + (item.unread ? " unread" : ""), tabIndex: 0, role: "button" },
393
+ kids,
394
+ );
395
+ row.onclick = (ev) => {
396
+ if (!ev.target.closest("button, a")) openThread(item);
397
+ };
398
+ row.onkeydown = (ev) => {
399
+ if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
400
+ ev.preventDefault();
401
+ openThread(item);
402
+ }
403
+ };
404
+ return row;
405
+ }
406
+
323
407
  function closedRow(e) {
324
408
  const note = el("span", { className: "meta" });
325
409
  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" }, [
410
+ const row = clickable(e.item, "homerow homeclosed", [
327
411
  el("div", {}, [
328
412
  openLink(e.item),
329
413
  el("div", { className: "meta", textContent: whereOf(e) + " · " + (e.reason || "No reason given.") }),
@@ -362,13 +446,33 @@ function openLink(item) {
362
446
  return b;
363
447
  }
364
448
 
449
+ /** Repaint Home where you were: the sheet may have changed what is on it, not where you are. */
450
+ function repaintInPlace() {
451
+ const main = document.querySelector("#main");
452
+ const at = { win: window.scrollY, main: main?.scrollTop ?? 0 };
453
+ render();
454
+ window.scrollTo(0, at.win);
455
+ if (main) main.scrollTop = at.main;
456
+ }
457
+
458
+ /**
459
+ * The thread in a side sheet: its header, actions, reply box and conversation. Closing it
460
+ * repaints Home, since a reply or a close there changes what belongs where. Where there is no
461
+ * <dialog> (the test shim), it falls back to the thread on Trackers.
462
+ */
365
463
  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
- });
464
+ const ref = { repo: item.repo, number: item.number, kind: item.kind };
465
+ const body = el("div");
466
+ const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
467
+ x.innerHTML = iconHTML("close");
468
+ body.append(el("div", { className: "sidebar-x" }, [x]));
469
+ threadInto(body, ref);
470
+ const box = sheet({ node: body, side: true, onClose: () => S.view === "home" && repaintInPlace() });
471
+ if (!box) {
472
+ go({ view: item.kind === "pr" ? "prs" : "inbox", inboxStaff: "", inboxFilter: "", inboxOpen: ref });
473
+ return;
474
+ }
475
+ x.onclick = () => box.close();
372
476
  }
373
477
 
374
478
  function whereOf(e) {
@@ -385,7 +489,7 @@ function button(label, primary, onclick) {
385
489
  /** After a write: re-read the inbox, and look for the run it starts. */
386
490
  function refreshSoon() {
387
491
  ensureInbox(true)
388
- .then(() => S.view === "home" && render())
492
+ .then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
389
493
  .catch(() => {});
390
494
  setTimeout(poll, 3_000);
391
495
  }
@@ -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.