eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The GitHub companion app (docs/connected-services.md § companion apps,
2// docs/integrations.md). Once you've linked GitHub in Integrations (a
3// fine-grained PAT), this app "installs": it appears in New chat and opens as
4// its own thread — your repositories, with a drill-in to any repo's open
5// issues and a form to open one.
6//
7// There is NO GitHub sidecar and no new bot: every fetch goes through the host
8// bridge (ctx.host.connected.requestOp), which seals your token to the
9// instance's EXISTING bot for one op — read-scoped for lists, write-scoped
10// only when you submit the new-issue form — and the bot answers with a
11// non-rendering connect.result. What comes back is bounded by the github
12// manifest's `pick` projection (integrations/registry/), so only the reviewed
13// fields ever ride into the thread. The server relays ciphertext throughout.
14//
15// It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so repo
16// and issue data never echoes into chat bubbles, previews, or notifications.
17import { defineApp } from "../apps.js";
18import { LOGO_GITHUB } from "../logos.js";
19
20// Results arrive as pick-projected rows with flattened dot-path keys
21// ("user.login") — normalise to plain fields once, at fold time, and tolerate
22// anything malformed by dropping it.
23const normalizeRepos = (list) =>
24 (Array.isArray(list) ? list : [])
25 .filter((r) => r && typeof r.full_name === "string")
26 .map((r) => ({
27 full_name: r.full_name,
28 description: typeof r.description === "string" ? r.description : "",
29 private: !!r.private,
30 archived: !!r.archived,
31 fork: !!r.fork,
32 html_url: typeof r.html_url === "string" ? r.html_url : "",
33 open_issues: typeof r.open_issues_count === "number" ? r.open_issues_count : null,
34 stars: typeof r.stargazers_count === "number" ? r.stargazers_count : null,
35 pushed_at: typeof r.pushed_at === "string" ? r.pushed_at : "",
36 }));
37
38const normalizeIssues = (list) =>
39 (Array.isArray(list) ? list : [])
40 .filter((i) => i && typeof i.number === "number" && typeof i.title === "string")
41 .map((i) => ({
42 number: i.number,
43 title: i.title,
44 html_url: typeof i.html_url === "string" ? i.html_url : "",
45 by: typeof i["user.login"] === "string" ? i["user.login"] : "",
46 comments: typeof i.comments === "number" ? i.comments : 0,
47 updated_at: typeof i.updated_at === "string" ? i.updated_at : "",
48 is_pr: !!i["pull_request.url"],
49 draft: !!i.draft,
50 }));
51
52// "3d ago"-style relative stamp for pushed_at/updated_at; "" when unparsable.
53function ago(iso) {
54 const t = Date.parse(iso || "");
55 if (!t) return "";
56 const s = Math.max(0, (Date.now() - t) / 1000);
57 if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`;
58 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
59 if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
60 const d = new Date(t);
61 return d.toISOString().slice(0, 10);
62}
63
64// Bound the folded issues map: keep the most recently fetched handful of
65// repos so the snapshot never balloons (the repo list itself is one array).
66const MAX_ISSUE_REPOS = 6;
67function trimIssues(issues) {
68 const keys = Object.keys(issues);
69 if (keys.length <= MAX_ISSUE_REPOS) return issues;
70 keys.sort((a, b) => (issues[b].fetchedAt || 0) - (issues[a].fetchedAt || 0));
71 const out = {};
72 for (const k of keys.slice(0, MAX_ISSUE_REPOS)) out[k] = issues[k];
73 return out;
74}
75
76// Module-level UI state (transient to the surface, never folded): the open
77// repo (null = repo list), the live filter, and in-flight flags.
78let openRepo = null;
79let filterText = "";
80let busy = false;
81let autoLoading = false;
82
83export const app = defineApp({
84 name: "github",
85 title: "GitHub",
86 glyph: "🐙",
87 mark: "github", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
88 logo: LOGO_GITHUB,
89 places: ["you"],
90 // Only listed in New chat once GitHub is linked (shell.js gates on this).
91 requiresService: "github",
92 initialState: () => ({ repos: [], fetchedAt: 0, issues: {} }),
93
94 // Two events: the fetched repo list (latest refresh replaces), and one
95 // repo's fetched open issues (keyed by full_name, trimmed to a handful).
96 events: {
97 "github.repos": (s, e) =>
98 Array.isArray(e.repos) ? { ...s, repos: normalizeRepos(e.repos), fetchedAt: e.ts || s.fetchedAt } : s,
99 "github.issues": (s, e) =>
100 typeof e.repo === "string" && Array.isArray(e.issues)
101 ? { ...s, issues: trimIssues({ ...s.issues, [e.repo]: { items: normalizeIssues(e.issues), fetchedAt: e.ts || Date.now() } }) }
102 : s,
103 },
104
105 styles: `
106.gh-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
107.gh-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
108.gh-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
109.gh-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); }
110.gh-btn:disabled { opacity: .6; cursor: default; }
111.gh-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
112.gh-filter { width: 100%; box-sizing: border-box; padding: 8px 12px; margin: 0 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; }
113.gh-filter:focus { outline: none; border-color: var(--accent); }
114.gh-scroll { overflow-x: auto; margin: 0 -4px; }
115.gh-table { width: 100%; border-collapse: collapse; font-size: 14px; }
116.gh-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
117.gh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
118.gh-table tbody tr:hover { background: var(--bubble-them); }
119.gh-table tbody tr.gh-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); }
120.gh-name { font-weight: 500; overflow-wrap: anywhere; }
121.gh-name a { color: var(--accent); text-decoration: none; }
122.gh-name a:hover { text-decoration: underline; }
123.gh-desc { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
124.gh-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; margin-left: 6px; vertical-align: 1px; white-space: nowrap; }
125.gh-c { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
126.gh-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
127.gh-empty { color: var(--muted); padding: 8px 0; }
128.gh-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
129.gh-form { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; }
130.gh-form input, .gh-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); color: var(--ink); font: inherit; }
131.gh-form textarea { min-height: 64px; resize: vertical; }
132.gh-form-row { display: flex; gap: 8px; justify-content: flex-end; }
133`,
134
135 render(state, el, ctx) {
136 const host = ctx.host && ctx.host.connected;
137 // appStateFor returns undefined for a thread with no folded state yet, so
138 // normalise before touching any field.
139 state = state || { repos: [], fetchedAt: 0, issues: {} };
140 const repos = normalizeRepos(state.repos);
141 const issues = state.issues || {};
142
143 const head = document.createElement("div");
144 head.className = "gh-head";
145 el.appendChild(head);
146
147 const note = document.createElement("div");
148 note.className = "gh-note";
149
150 // No privileged bridge (or a stale page that predates requestOp) — the
151 // native app can't seal grants yet; connected services are web-only.
152 if (!host || !host.requestOp) {
153 const title = document.createElement("h2");
154 title.className = "gh-title";
155 title.textContent = "GitHub";
156 head.appendChild(title);
157 const empty = document.createElement("div");
158 empty.className = "gh-empty";
159 empty.textContent = "GitHub needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
160 el.append(empty);
161 return;
162 }
163
164 // Both fetchers run their WHOLE body under try/finally: a throw anywhere
165 // (even a plain bug) must land in fail() and release `busy` — a wedged
166 // module-level flag silently swallows every later Refresh, which is
167 // exactly the failure that shipped: paint() here hit the temporal dead
168 // zone when the auto-load fired mid-render, threw before the old finally,
169 // and froze the app on "loading…" for good.
170 const fetchRepos = async () => {
171 if (busy) return;
172 busy = true;
173 let res;
174 try {
175 note.textContent = "";
176 paint();
177 res = await host.requestOp("github", "repos.list", {});
178 } catch (e) {
179 res = { ok: false, error: (e && e.message) || String(e) };
180 } finally {
181 busy = false;
182 autoLoading = false;
183 }
184 if (res && res.ok && typeof res.data === "string") {
185 let rows;
186 try {
187 rows = JSON.parse(res.data);
188 } catch {
189 rows = null;
190 }
191 // Emit as an event: it folds into state (persists via the snapshot and
192 // syncs across devices) and re-renders with the fresh list.
193 if (rows) return ctx.emit({ type: "github.repos", repos: rows });
194 }
195 fail((res && res.error) || "Couldn't reach GitHub just now.", repos.length > 0);
196 };
197
198 const fetchIssues = async (fullName) => {
199 if (busy) return;
200 const [owner, repo] = fullName.split("/");
201 if (!owner || !repo) return;
202 busy = true;
203 let res;
204 try {
205 note.textContent = "";
206 paint();
207 res = await host.requestOp("github", "issues.list", { owner, repo });
208 } catch (e) {
209 res = { ok: false, error: (e && e.message) || String(e) };
210 } finally {
211 busy = false;
212 }
213 if (res && res.ok && typeof res.data === "string") {
214 let rows;
215 try {
216 rows = JSON.parse(res.data);
217 } catch {
218 rows = null;
219 }
220 if (rows) return ctx.emit({ type: "github.issues", repo: fullName, issues: rows });
221 }
222 const have = issues[fullName] && issues[fullName].items.length;
223 fail((res && res.error) || "Couldn't reach GitHub just now.", !!have);
224 };
225
226 // Loud failure in the main area when there's nothing to show; a quiet note
227 // when stale data is still on screen — a failed load must never look empty.
228 const fail = (msg, haveData) => {
229 if (haveData) {
230 note.textContent = `Couldn't refresh: ${msg}`;
231 } else {
232 listOrEmpty.className = "gh-empty";
233 listOrEmpty.textContent = `Couldn't load from GitHub: ${msg}`;
234 }
235 paint();
236 };
237
238 // ---- shared chrome: filter box + list area -----------------------------
239 const filter = document.createElement("input");
240 filter.className = "gh-filter";
241 filter.type = "search";
242 filter.value = filterText;
243 filter.hidden = true;
244 filter.addEventListener("input", () => {
245 filterText = filter.value;
246 activeIndex = 0;
247 drawList();
248 });
249 filter.addEventListener("keydown", (e) => {
250 if (e.key === "ArrowDown") {
251 e.preventDefault();
252 setActive(activeIndex + 1);
253 } else if (e.key === "ArrowUp") {
254 e.preventDefault();
255 setActive(activeIndex - 1);
256 } else if (e.key === "Enter") {
257 e.preventDefault();
258 enterActive();
259 } else if (e.key === "Escape" && filterText) {
260 e.preventDefault();
261 filter.value = "";
262 filterText = "";
263 activeIndex = 0;
264 drawList();
265 }
266 });
267
268 const listOrEmpty = document.createElement("div");
269
270 // Keyboard-nav state shared by both tables.
271 let activeIndex = 0;
272 let currentRows = [];
273 let rowEls = [];
274 const highlight = () => {
275 rowEls.forEach((tr, i) => tr.classList.toggle("gh-active", i === activeIndex));
276 const active = rowEls[activeIndex];
277 if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" });
278 };
279 const setActive = (i) => {
280 if (!currentRows.length) return;
281 activeIndex = Math.max(0, Math.min(i, currentRows.length - 1));
282 highlight();
283 };
284 const enterActive = () => {
285 const row = currentRows[activeIndex];
286 if (!row) return;
287 if (openRepo) {
288 if (row.html_url) window.open(row.html_url, "_blank", "noopener");
289 } else {
290 openIssues(row.full_name);
291 }
292 };
293 const restoreFocus = () => {
294 // Focus the filter so ↑/↓/Enter work immediately — but never steal focus
295 // (e.g. from the new-issue form or the chat composer).
296 if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true });
297 };
298
299 // The framework re-renders on fold; a pure view switch (repo list ⇄ one
300 // repo's issues) re-enters render by hand, reading fresh folded state.
301 const redraw = () => {
302 el.textContent = "";
303 app.render(ctx.getState(), el, ctx);
304 };
305 const openIssues = (fullName) => {
306 openRepo = fullName;
307 filterText = "";
308 activeIndex = 0;
309 redraw();
310 };
311
312 // ---- repo list view ----------------------------------------------------
313 const drawRepos = () => {
314 filter.placeholder = "Filter repositories… (↑ ↓ to move, Enter to open its issues)";
315 filter.hidden = !repos.length;
316 listOrEmpty.textContent = "";
317 if (!repos.length) {
318 currentRows = [];
319 rowEls = [];
320 listOrEmpty.className = "gh-empty";
321 listOrEmpty.textContent = state.fetchedAt ? "No repositories visible to this token." : "No repositories loaded yet — hit Refresh.";
322 return;
323 }
324 const q = filterText.trim().toLowerCase();
325 const rows = repos.filter((r) => !q || r.full_name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q));
326 currentRows = rows;
327 rowEls = [];
328 if (!rows.length) {
329 listOrEmpty.className = "gh-empty";
330 listOrEmpty.textContent = `No repositories match “${filterText.trim()}”.`;
331 return;
332 }
333 activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1));
334 listOrEmpty.className = "";
335 const scroll = document.createElement("div");
336 scroll.className = "gh-scroll";
337 const table = document.createElement("table");
338 table.className = "gh-table";
339 const thead = document.createElement("thead");
340 const hr = document.createElement("tr");
341 for (const [label, cls] of [["Repository", ""], ["Issues", "gh-c"], ["Stars", "gh-c"], ["Pushed", "gh-c"]]) {
342 const th = document.createElement("th");
343 th.textContent = label;
344 if (cls) th.className = cls;
345 hr.appendChild(th);
346 }
347 thead.appendChild(hr);
348 table.appendChild(thead);
349 const tbody = document.createElement("tbody");
350 rows.forEach((r, i) => {
351 const tr = document.createElement("tr");
352 tr.onclick = () => setActive(i);
353 tr.ondblclick = () => openIssues(r.full_name);
354
355 const nameCell = document.createElement("td");
356 nameCell.className = "gh-name";
357 const open = document.createElement("a");
358 open.href = "#";
359 open.textContent = r.full_name;
360 open.onclick = (e) => {
361 e.preventDefault();
362 openIssues(r.full_name);
363 };
364 nameCell.appendChild(open);
365 for (const [flag, label] of [[r.private, "private"], [r.archived, "archived"], [r.fork, "fork"]]) {
366 if (!flag) continue;
367 const b = document.createElement("span");
368 b.className = "gh-badge";
369 b.textContent = label;
370 nameCell.appendChild(b);
371 }
372 if (r.description) {
373 const d = document.createElement("div");
374 d.className = "gh-desc";
375 d.textContent = r.description;
376 nameCell.appendChild(d);
377 }
378
379 const issuesCell = document.createElement("td");
380 issuesCell.className = "gh-c";
381 issuesCell.textContent = r.open_issues === null ? "—" : String(r.open_issues);
382 const starsCell = document.createElement("td");
383 starsCell.className = "gh-c";
384 starsCell.textContent = r.stars === null ? "—" : String(r.stars);
385 const whenCell = document.createElement("td");
386 whenCell.className = "gh-c gh-when";
387 whenCell.textContent = ago(r.pushed_at) || "—";
388
389 tr.append(nameCell, issuesCell, starsCell, whenCell);
390 rowEls.push(tr);
391 tbody.appendChild(tr);
392 });
393 table.appendChild(tbody);
394 scroll.appendChild(table);
395 listOrEmpty.appendChild(scroll);
396 highlight();
397 restoreFocus();
398 };
399
400 // ---- issues view -------------------------------------------------------
401 const drawIssues = () => {
402 const entry = issues[openRepo];
403 const items = entry ? entry.items : [];
404 filter.placeholder = "Filter issues… (↑ ↓ to move, Enter to open on github.com)";
405 filter.hidden = !items.length;
406 listOrEmpty.textContent = "";
407 if (!items.length) {
408 currentRows = [];
409 rowEls = [];
410 listOrEmpty.className = "gh-empty";
411 listOrEmpty.textContent = entry ? "No open issues — clear!" : "Loading issues…";
412 return;
413 }
414 const q = filterText.trim().toLowerCase();
415 const rows = items.filter((i) => !q || i.title.toLowerCase().includes(q) || String(i.number).includes(q));
416 currentRows = rows;
417 rowEls = [];
418 if (!rows.length) {
419 listOrEmpty.className = "gh-empty";
420 listOrEmpty.textContent = `No issues match “${filterText.trim()}”.`;
421 return;
422 }
423 activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1));
424 listOrEmpty.className = "";
425 const scroll = document.createElement("div");
426 scroll.className = "gh-scroll";
427 const table = document.createElement("table");
428 table.className = "gh-table";
429 const thead = document.createElement("thead");
430 const hr = document.createElement("tr");
431 for (const [label, cls] of [["#", ""], ["Title", ""], ["By", ""], ["💬", "gh-c"], ["Updated", "gh-c"]]) {
432 const th = document.createElement("th");
433 th.textContent = label;
434 if (cls) th.className = cls;
435 hr.appendChild(th);
436 }
437 thead.appendChild(hr);
438 table.appendChild(thead);
439 const tbody = document.createElement("tbody");
440 rows.forEach((it, i) => {
441 const tr = document.createElement("tr");
442 tr.onclick = () => setActive(i);
443
444 const numCell = document.createElement("td");
445 numCell.className = "gh-when";
446 numCell.textContent = `#${it.number}`;
447 const titleCell = document.createElement("td");
448 titleCell.className = "gh-name";
449 if (it.html_url) {
450 const a = document.createElement("a");
451 a.href = it.html_url;
452 a.target = "_blank";
453 a.rel = "noopener noreferrer";
454 a.textContent = it.title;
455 titleCell.appendChild(a);
456 } else {
457 titleCell.textContent = it.title;
458 }
459 if (it.is_pr) {
460 const b = document.createElement("span");
461 b.className = "gh-badge";
462 b.textContent = it.draft ? "draft PR" : "PR";
463 titleCell.appendChild(b);
464 }
465 const byCell = document.createElement("td");
466 byCell.className = "gh-when";
467 byCell.textContent = it.by || "—";
468 const cCell = document.createElement("td");
469 cCell.className = "gh-c";
470 cCell.textContent = it.comments ? String(it.comments) : "";
471 const whenCell = document.createElement("td");
472 whenCell.className = "gh-c gh-when";
473 whenCell.textContent = ago(it.updated_at) || "—";
474
475 tr.append(numCell, titleCell, byCell, cCell, whenCell);
476 rowEls.push(tr);
477 tbody.appendChild(tr);
478 });
479 table.appendChild(tbody);
480 scroll.appendChild(table);
481 listOrEmpty.appendChild(scroll);
482 highlight();
483 restoreFocus();
484 };
485
486 // The new-issue form: your submission IS the consent for this one write —
487 // the grant minted for it is write-scoped and single-use (docs/integrations.md).
488 const buildIssueForm = () => {
489 const form = document.createElement("div");
490 form.className = "gh-form";
491 form.hidden = true;
492 const title = document.createElement("input");
493 title.placeholder = "Issue title";
494 title.maxLength = 256;
495 const body = document.createElement("textarea");
496 body.placeholder = "Describe it (optional)";
497 const row = document.createElement("div");
498 row.className = "gh-form-row";
499 const cancel = document.createElement("button");
500 cancel.type = "button";
501 cancel.className = "gh-btn";
502 cancel.textContent = "Cancel";
503 cancel.onclick = () => (form.hidden = true);
504 const submit = document.createElement("button");
505 submit.type = "button";
506 submit.className = "gh-btn gh-btn-primary";
507 submit.textContent = "Open issue";
508 submit.onclick = async () => {
509 const t = title.value.trim();
510 if (!t) {
511 note.textContent = "Give the issue a title first.";
512 return;
513 }
514 const [owner, repo] = openRepo.split("/");
515 submit.disabled = true;
516 submit.textContent = "Opening…";
517 const res = await host.requestOp("github", "issue.create", { owner, repo, title: t, body: body.value.trim() }, "write");
518 submit.disabled = false;
519 submit.textContent = "Open issue";
520 if (!res.ok) {
521 note.textContent = `Couldn't open the issue: ${res.error}`;
522 return;
523 }
524 title.value = "";
525 body.value = "";
526 form.hidden = true;
527 let opened = "";
528 try {
529 const d = JSON.parse(res.data);
530 opened = d && d.number ? ` #${d.number}` : "";
531 } catch {}
532 note.textContent = `Issue${opened} opened ✓`;
533 fetchIssues(openRepo); // pull the fresh list so it shows up
534 };
535 row.append(cancel, submit);
536 form.append(title, body, row);
537 return form;
538 };
539
540 // ---- header + assembly, per view ---------------------------------------
541 const title = document.createElement("h2");
542 title.className = "gh-title";
543 const sub = document.createElement("span");
544 sub.className = "gh-sub";
545
546 let drawList;
547 if (openRepo) {
548 const back = document.createElement("button");
549 back.type = "button";
550 back.className = "gh-btn";
551 back.textContent = "‹ Repos";
552 back.onclick = () => {
553 openRepo = null;
554 filterText = "";
555 activeIndex = 0;
556 redraw();
557 };
558 title.textContent = openRepo;
559 const newIssue = document.createElement("button");
560 newIssue.type = "button";
561 newIssue.className = "gh-btn";
562 newIssue.textContent = "+ New issue";
563 const refresh = document.createElement("button");
564 refresh.type = "button";
565 refresh.className = "gh-btn";
566 refresh.textContent = "↻";
567 refresh.onclick = () => fetchIssues(openRepo);
568 head.append(back, title, sub, newIssue, refresh);
569
570 const form = buildIssueForm();
571 newIssue.onclick = () => {
572 form.hidden = !form.hidden;
573 if (!form.hidden) form.querySelector("input").focus();
574 };
575 el.append(form, filter, listOrEmpty, note);
576
577 drawList = drawIssues;
578 } else {
579 title.textContent = "Your repositories";
580 const refresh = document.createElement("button");
581 refresh.type = "button";
582 refresh.className = "gh-btn";
583 refresh.textContent = "↻ Refresh";
584 refresh.onclick = fetchRepos;
585 head.append(title, sub, refresh);
586 el.append(filter, listOrEmpty, note);
587
588 drawList = drawRepos;
589 }
590
591 // Transient bits (busy label, count line) live outside folded state.
592 const paint = () => {
593 sub.textContent = busy
594 ? "loading…"
595 : openRepo
596 ? (issues[openRepo] ? `${issues[openRepo].items.length} open` : "")
597 : repos.length
598 ? `${repos.length} repositor${repos.length === 1 ? "y" : "ies"}`
599 : "";
600 };
601
602 drawList();
603 paint();
604
605 // Auto-loads kick off ONLY after the surface is fully built — the
606 // fetchers call paint(), so firing them mid-render hit paint's temporal
607 // dead zone: a ReferenceError before the busy-reset, and the app wedged
608 // on "loading…" with every later Refresh silently swallowed.
609 if (openRepo) {
610 const entry = issues[openRepo];
611 // Opened with nothing (or stale > 2 min) → pull once, automatically.
612 if (!busy && (!entry || Date.now() - (entry.fetchedAt || 0) > 120000)) fetchIssues(openRepo);
613 } else if (!state.fetchedAt && !busy && !autoLoading) {
614 autoLoading = true;
615 fetchRepos();
616 }
617 },
618});
619