| new file mode 100644 |
| index 0000000..c28edfc |
| --- /dev/null |
| +++ b/web/static/apps/linear.js |
| @@ -0,0 +1,578 @@ |
| +// The Linear companion app (docs/connected-services.md Β§ companion apps, |
| +// docs/integrations.md). Once you've linked Linear in Integrations (OAuth or |
| +// a personal API key), this app "installs": it appears in New chat and opens |
| +// as its own thread β your teams, with a drill-in to any team's active issues |
| +// and a form to create one. |
| +// |
| +// Same trust shape as the GitHub app: NO Linear sidecar and no new bot β every |
| +// fetch goes through the host bridge (ctx.host.connected.requestOp), which |
| +// seals your credential to the instance's EXISTING bot for one op β |
| +// read-scoped for lists, write-scoped only when you submit the new-issue form. |
| +// What comes back is bounded by the linear manifest's `pick` projection |
| +// (integrations/registry/), so only the reviewed fields ever ride into the |
| +// thread. The server relays ciphertext throughout. |
| +// |
| +// It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so team |
| +// and issue data never echoes into chat bubbles, previews, or notifications. |
| +import { defineApp } from "../apps.js"; |
| + |
| +// Results arrive as pick-projected rows with flattened dot-path keys |
| +// ("state.name") β normalise to plain fields once, at fold time, and tolerate |
| +// anything malformed by dropping it. |
| +const normalizeTeams = (list) => |
| + (Array.isArray(list) ? list : []) |
| + .filter((t) => t && typeof t.key === "string" && t.key && typeof t.id === "string" && t.id) |
| + .map((t) => ({ |
| + id: t.id, // the UUID issue.create needs |
| + key: t.key, // the short code issues.list filters by ("ENG") |
| + name: typeof t.name === "string" ? t.name : t.key, |
| + })); |
| + |
| +const normalizeIssues = (list) => |
| + (Array.isArray(list) ? list : []) |
| + .filter((i) => i && typeof i.identifier === "string" && i.identifier && typeof i.title === "string") |
| + .map((i) => ({ |
| + identifier: i.identifier, |
| + title: i.title, |
| + url: typeof i.url === "string" ? i.url : "", |
| + state: typeof i["state.name"] === "string" ? i["state.name"] : "", |
| + assignee: typeof i["assignee.displayName"] === "string" ? i["assignee.displayName"] : "", |
| + priority: typeof i.priorityLabel === "string" ? i.priorityLabel : "", |
| + updated_at: typeof i.updatedAt === "string" ? i.updatedAt : "", |
| + })); |
| + |
| +// "3d ago"-style relative stamp for updatedAt; "" when unparsable. |
| +function ago(iso) { |
| + const t = Date.parse(iso || ""); |
| + if (!t) return ""; |
| + const s = Math.max(0, (Date.now() - t) / 1000); |
| + if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`; |
| + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; |
| + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; |
| + const d = new Date(t); |
| + return d.toISOString().slice(0, 10); |
| +} |
| + |
| +// Bound the folded issues map: keep the most recently fetched handful of |
| +// teams so the snapshot never balloons (the team list itself is one array). |
| +const MAX_ISSUE_TEAMS = 6; |
| +function trimIssues(issues) { |
| + const keys = Object.keys(issues); |
| + if (keys.length <= MAX_ISSUE_TEAMS) return issues; |
| + keys.sort((a, b) => (issues[b].fetchedAt || 0) - (issues[a].fetchedAt || 0)); |
| + const out = {}; |
| + for (const k of keys.slice(0, MAX_ISSUE_TEAMS)) out[k] = issues[k]; |
| + return out; |
| +} |
| + |
| +// Module-level UI state (transient to the surface, never folded): the open |
| +// team (null = team list), the live filter, and in-flight flags. |
| +let openTeam = null; // { id, key, name } |
| +let filterText = ""; |
| +let busy = false; |
| +let autoLoading = false; |
| + |
| +export const app = defineApp({ |
| + name: "linear", |
| + title: "Linear", |
| + glyph: "π", |
| + scope: "personal", |
| + // Only listed in New chat once Linear is linked (shell.js gates on this). |
| + requiresService: "linear", |
| + initialState: () => ({ teams: [], fetchedAt: 0, issues: {} }), |
| + |
| + // Two events: the fetched team list (latest refresh replaces), and one |
| + // team's fetched active issues (keyed by team key, trimmed to a handful). |
| + events: { |
| + "linear.teams": (s, e) => |
| + Array.isArray(e.teams) ? { ...s, teams: normalizeTeams(e.teams), fetchedAt: e.ts || s.fetchedAt } : s, |
| + "linear.issues": (s, e) => |
| + typeof e.team === "string" && Array.isArray(e.issues) |
| + ? { ...s, issues: trimIssues({ ...s.issues, [e.team]: { items: normalizeIssues(e.issues), fetchedAt: e.ts || Date.now() } }) } |
| + : s, |
| + }, |
| + |
| + styles: ` |
| +.ln-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } |
| +.ln-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; } |
| +.ln-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } |
| +.ln-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); } |
| +.ln-btn:disabled { opacity: .6; cursor: default; } |
| +.ln-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } |
| +.ln-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; } |
| +.ln-filter:focus { outline: none; border-color: var(--accent); } |
| +.ln-scroll { overflow-x: auto; margin: 0 -4px; } |
| +.ln-table { width: 100%; border-collapse: collapse; font-size: 14px; } |
| +.ln-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; } |
| +.ln-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } |
| +.ln-table tbody tr:hover { background: var(--bubble-them); } |
| +.ln-table tbody tr.ln-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); } |
| +.ln-name { font-weight: 500; overflow-wrap: anywhere; } |
| +.ln-name a { color: var(--accent); text-decoration: none; } |
| +.ln-name a:hover { text-decoration: underline; } |
| +.ln-key { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| +.ln-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; } |
| +.ln-c { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| +.ln-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| +.ln-empty { color: var(--muted); padding: 8px 0; } |
| +.ln-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } |
| +.ln-form { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; } |
| +.ln-form input, .ln-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; } |
| +.ln-form textarea { min-height: 64px; resize: vertical; } |
| +.ln-form-row { display: flex; gap: 8px; justify-content: flex-end; } |
| +`, |
| + |
| + render(state, el, ctx) { |
| + const host = ctx.host && ctx.host.connected; |
| + state = state || { teams: [], fetchedAt: 0, issues: {} }; |
| + const teams = normalizeTeams(state.teams); |
| + const issues = state.issues || {}; |
| + |
| + const head = document.createElement("div"); |
| + head.className = "ln-head"; |
| + el.appendChild(head); |
| + |
| + const note = document.createElement("div"); |
| + note.className = "ln-note"; |
| + |
| + // No privileged bridge (or a stale page that predates requestOp) β the |
| + // native app can't seal grants yet; connected services are web-only. |
| + if (!host || !host.requestOp) { |
| + const title = document.createElement("h2"); |
| + title.className = "ln-title"; |
| + title.textContent = "Linear"; |
| + head.appendChild(title); |
| + const empty = document.createElement("div"); |
| + empty.className = "ln-empty"; |
| + empty.textContent = "Linear needs the web app for now β open this chat in a web browser (connecting isn't supported in the app yet)."; |
| + el.append(empty); |
| + return; |
| + } |
| + |
| + // Both fetchers run their WHOLE body under try/finally: a throw anywhere |
| + // must land in fail() and release `busy` β a wedged module-level flag |
| + // silently swallows every later Refresh (the GitHub app shipped exactly |
| + // that bug; see its header comment). |
| + const fetchTeams = async () => { |
| + if (busy) return; |
| + busy = true; |
| + let res; |
| + try { |
| + note.textContent = ""; |
| + paint(); |
| + res = await host.requestOp("linear", "teams.list", {}); |
| + } catch (e) { |
| + res = { ok: false, error: (e && e.message) || String(e) }; |
| + } finally { |
| + busy = false; |
| + autoLoading = false; |
| + } |
| + if (res && res.ok && typeof res.data === "string") { |
| + let rows; |
| + try { |
| + rows = JSON.parse(res.data); |
| + } catch { |
| + rows = null; |
| + } |
| + // Emit as an event: it folds into state (persists via the snapshot and |
| + // syncs across devices) and re-renders with the fresh list. |
| + if (rows) return ctx.emit({ type: "linear.teams", teams: rows }); |
| + } |
| + fail((res && res.error) || "Couldn't reach Linear just now.", teams.length > 0); |
| + }; |
| + |
| + const fetchIssues = async (teamKey) => { |
| + if (busy) return; |
| + busy = true; |
| + let res; |
| + try { |
| + note.textContent = ""; |
| + paint(); |
| + res = await host.requestOp("linear", "issues.list", { team: teamKey }); |
| + } catch (e) { |
| + res = { ok: false, error: (e && e.message) || String(e) }; |
| + } finally { |
| + busy = false; |
| + } |
| + if (res && res.ok && typeof res.data === "string") { |
| + let rows; |
| + try { |
| + rows = JSON.parse(res.data); |
| + } catch { |
| + rows = null; |
| + } |
| + if (rows) return ctx.emit({ type: "linear.issues", team: teamKey, issues: rows }); |
| + } |
| + const have = issues[teamKey] && issues[teamKey].items.length; |
| + fail((res && res.error) || "Couldn't reach Linear just now.", !!have); |
| + }; |
| + |
| + // Loud failure in the main area when there's nothing to show; a quiet note |
| + // when stale data is still on screen β a failed load must never look empty. |
| + const fail = (msg, haveData) => { |
| + if (haveData) { |
| + note.textContent = `Couldn't refresh: ${msg}`; |
| + } else { |
| + listOrEmpty.className = "ln-empty"; |
| + listOrEmpty.textContent = `Couldn't load from Linear: ${msg}`; |
| + } |
| + paint(); |
| + }; |
| + |
| + // ---- shared chrome: filter box + list area ----------------------------- |
| + const filter = document.createElement("input"); |
| + filter.className = "ln-filter"; |
| + filter.type = "search"; |
| + filter.value = filterText; |
| + filter.hidden = true; |
| + filter.addEventListener("input", () => { |
| + filterText = filter.value; |
| + activeIndex = 0; |
| + drawList(); |
| + }); |
| + filter.addEventListener("keydown", (e) => { |
| + if (e.key === "ArrowDown") { |
| + e.preventDefault(); |
| + setActive(activeIndex + 1); |
| + } else if (e.key === "ArrowUp") { |
| + e.preventDefault(); |
| + setActive(activeIndex - 1); |
| + } else if (e.key === "Enter") { |
| + e.preventDefault(); |
| + enterActive(); |
| + } else if (e.key === "Escape" && filterText) { |
| + e.preventDefault(); |
| + filter.value = ""; |
| + filterText = ""; |
| + activeIndex = 0; |
| + drawList(); |
| + } |
| + }); |
| + |
| + const listOrEmpty = document.createElement("div"); |
| + |
| + // Keyboard-nav state shared by both tables. |
| + let activeIndex = 0; |
| + let currentRows = []; |
| + let rowEls = []; |
| + const highlight = () => { |
| + rowEls.forEach((tr, i) => tr.classList.toggle("ln-active", i === activeIndex)); |
| + const active = rowEls[activeIndex]; |
| + if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" }); |
| + }; |
| + const setActive = (i) => { |
| + if (!currentRows.length) return; |
| + activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); |
| + highlight(); |
| + }; |
| + const enterActive = () => { |
| + const row = currentRows[activeIndex]; |
| + if (!row) return; |
| + if (openTeam) { |
| + if (row.url) window.open(row.url, "_blank", "noopener"); |
| + } else { |
| + openIssues(row); |
| + } |
| + }; |
| + const restoreFocus = () => { |
| + // Focus the filter so β/β/Enter work immediately β but never steal focus |
| + // (e.g. from the new-issue form or the chat composer). |
| + if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true }); |
| + }; |
| + |
| + // The framework re-renders on fold; a pure view switch (team list β one |
| + // team's issues) re-enters render by hand, reading fresh folded state. |
| + const redraw = () => { |
| + el.textContent = ""; |
| + app.render(ctx.getState(), el, ctx); |
| + }; |
| + const openIssues = (team) => { |
| + openTeam = { id: team.id, key: team.key, name: team.name }; |
| + filterText = ""; |
| + activeIndex = 0; |
| + redraw(); |
| + }; |
| + |
| + // ---- team list view ----------------------------------------------------- |
| + const drawTeams = () => { |
| + filter.placeholder = "Filter teamsβ¦ (β β to move, Enter to open its issues)"; |
| + filter.hidden = !teams.length; |
| + listOrEmpty.textContent = ""; |
| + if (!teams.length) { |
| + currentRows = []; |
| + rowEls = []; |
| + listOrEmpty.className = "ln-empty"; |
| + listOrEmpty.textContent = state.fetchedAt ? "No teams visible to this connection." : "No teams loaded yet β hit Refresh."; |
| + return; |
| + } |
| + const q = filterText.trim().toLowerCase(); |
| + const rows = teams.filter((t) => !q || t.key.toLowerCase().includes(q) || t.name.toLowerCase().includes(q)); |
| + currentRows = rows; |
| + rowEls = []; |
| + if (!rows.length) { |
| + listOrEmpty.className = "ln-empty"; |
| + listOrEmpty.textContent = `No teams match β${filterText.trim()}β.`; |
| + return; |
| + } |
| + activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); |
| + listOrEmpty.className = ""; |
| + const scroll = document.createElement("div"); |
| + scroll.className = "ln-scroll"; |
| + const table = document.createElement("table"); |
| + table.className = "ln-table"; |
| + const thead = document.createElement("thead"); |
| + const hr = document.createElement("tr"); |
| + for (const label of ["Key", "Team"]) { |
| + const th = document.createElement("th"); |
| + th.textContent = label; |
| + hr.appendChild(th); |
| + } |
| + thead.appendChild(hr); |
| + table.appendChild(thead); |
| + const tbody = document.createElement("tbody"); |
| + rows.forEach((t, i) => { |
| + const tr = document.createElement("tr"); |
| + tr.onclick = () => setActive(i); |
| + tr.ondblclick = () => openIssues(t); |
| + |
| + const keyCell = document.createElement("td"); |
| + keyCell.className = "ln-key"; |
| + keyCell.textContent = t.key; |
| + const nameCell = document.createElement("td"); |
| + nameCell.className = "ln-name"; |
| + const open = document.createElement("a"); |
| + open.href = "#"; |
| + open.textContent = t.name; |
| + open.onclick = (e) => { |
| + e.preventDefault(); |
| + openIssues(t); |
| + }; |
| + nameCell.appendChild(open); |
| + |
| + tr.append(keyCell, nameCell); |
| + rowEls.push(tr); |
| + tbody.appendChild(tr); |
| + }); |
| + table.appendChild(tbody); |
| + scroll.appendChild(table); |
| + listOrEmpty.appendChild(scroll); |
| + highlight(); |
| + restoreFocus(); |
| + }; |
| + |
| + // ---- issues view -------------------------------------------------------- |
| + const drawIssues = () => { |
| + const entry = issues[openTeam.key]; |
| + const items = entry ? entry.items : []; |
| + filter.placeholder = "Filter issuesβ¦ (β β to move, Enter to open on linear.app)"; |
| + filter.hidden = !items.length; |
| + listOrEmpty.textContent = ""; |
| + if (!items.length) { |
| + currentRows = []; |
| + rowEls = []; |
| + listOrEmpty.className = "ln-empty"; |
| + listOrEmpty.textContent = entry ? "No active issues β clear!" : "Loading issuesβ¦"; |
| + return; |
| + } |
| + const q = filterText.trim().toLowerCase(); |
| + const rows = items.filter((i) => !q || i.title.toLowerCase().includes(q) || i.identifier.toLowerCase().includes(q) || i.assignee.toLowerCase().includes(q)); |
| + currentRows = rows; |
| + rowEls = []; |
| + if (!rows.length) { |
| + listOrEmpty.className = "ln-empty"; |
| + listOrEmpty.textContent = `No issues match β${filterText.trim()}β.`; |
| + return; |
| + } |
| + activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); |
| + listOrEmpty.className = ""; |
| + const scroll = document.createElement("div"); |
| + scroll.className = "ln-scroll"; |
| + const table = document.createElement("table"); |
| + table.className = "ln-table"; |
| + const thead = document.createElement("thead"); |
| + const hr = document.createElement("tr"); |
| + for (const [label, cls] of [["ID", ""], ["Title", ""], ["State", ""], ["Assignee", ""], ["Priority", ""], ["Updated", "ln-c"]]) { |
| + const th = document.createElement("th"); |
| + th.textContent = label; |
| + if (cls) th.className = cls; |
| + hr.appendChild(th); |
| + } |
| + thead.appendChild(hr); |
| + table.appendChild(thead); |
| + const tbody = document.createElement("tbody"); |
| + rows.forEach((it, i) => { |
| + const tr = document.createElement("tr"); |
| + tr.onclick = () => setActive(i); |
| + |
| + const idCell = document.createElement("td"); |
| + idCell.className = "ln-key"; |
| + idCell.textContent = it.identifier; |
| + const titleCell = document.createElement("td"); |
| + titleCell.className = "ln-name"; |
| + if (it.url) { |
| + const a = document.createElement("a"); |
| + a.href = it.url; |
| + a.target = "_blank"; |
| + a.rel = "noopener noreferrer"; |
| + a.textContent = it.title; |
| + titleCell.appendChild(a); |
| + } else { |
| + titleCell.textContent = it.title; |
| + } |
| + const stateCell = document.createElement("td"); |
| + stateCell.className = "ln-when"; |
| + stateCell.textContent = it.state || "β"; |
| + const whoCell = document.createElement("td"); |
| + whoCell.className = "ln-when"; |
| + whoCell.textContent = it.assignee || "β"; |
| + const prioCell = document.createElement("td"); |
| + prioCell.className = "ln-when"; |
| + prioCell.textContent = it.priority && it.priority !== "No priority" ? it.priority : "β"; |
| + const whenCell = document.createElement("td"); |
| + whenCell.className = "ln-c ln-when"; |
| + whenCell.textContent = ago(it.updated_at) || "β"; |
| + |
| + tr.append(idCell, titleCell, stateCell, whoCell, prioCell, whenCell); |
| + rowEls.push(tr); |
| + tbody.appendChild(tr); |
| + }); |
| + table.appendChild(tbody); |
| + scroll.appendChild(table); |
| + listOrEmpty.appendChild(scroll); |
| + highlight(); |
| + restoreFocus(); |
| + }; |
| + |
| + // The new-issue form: your submission IS the consent for this one write β |
| + // the grant minted for it is write-scoped and single-use |
| + // (docs/integrations.md). issue.create wants the team's UUID, which rode |
| + // in with teams.list β never typed by hand. |
| + const buildIssueForm = () => { |
| + const form = document.createElement("div"); |
| + form.className = "ln-form"; |
| + form.hidden = true; |
| + const title = document.createElement("input"); |
| + title.placeholder = "Issue title"; |
| + title.maxLength = 256; |
| + const body = document.createElement("textarea"); |
| + body.placeholder = "Describe it (optional)"; |
| + const row = document.createElement("div"); |
| + row.className = "ln-form-row"; |
| + const cancel = document.createElement("button"); |
| + cancel.type = "button"; |
| + cancel.className = "ln-btn"; |
| + cancel.textContent = "Cancel"; |
| + cancel.onclick = () => (form.hidden = true); |
| + const submit = document.createElement("button"); |
| + submit.type = "button"; |
| + submit.className = "ln-btn ln-btn-primary"; |
| + submit.textContent = "Create issue"; |
| + submit.onclick = async () => { |
| + const t = title.value.trim(); |
| + if (!t) { |
| + note.textContent = "Give the issue a title first."; |
| + return; |
| + } |
| + submit.disabled = true; |
| + submit.textContent = "Creatingβ¦"; |
| + const res = await host.requestOp("linear", "issue.create", { team_id: openTeam.id, title: t, description: body.value.trim() }, "write"); |
| + submit.disabled = false; |
| + submit.textContent = "Create issue"; |
| + if (!res.ok) { |
| + note.textContent = `Couldn't create the issue: ${res.error}`; |
| + return; |
| + } |
| + title.value = ""; |
| + body.value = ""; |
| + form.hidden = true; |
| + let made = ""; |
| + try { |
| + const d = JSON.parse(res.data); |
| + made = d && d.identifier ? ` ${d.identifier}` : ""; |
| + } catch {} |
| + note.textContent = `Issue${made} created β`; |
| + fetchIssues(openTeam.key); // pull the fresh list so it shows up |
| + }; |
| + row.append(cancel, submit); |
| + form.append(title, body, row); |
| + return form; |
| + }; |
| + |
| + // ---- header + assembly, per view ---------------------------------------- |
| + const title = document.createElement("h2"); |
| + title.className = "ln-title"; |
| + const sub = document.createElement("span"); |
| + sub.className = "ln-sub"; |
| + |
| + let drawList; |
| + if (openTeam) { |
| + const back = document.createElement("button"); |
| + back.type = "button"; |
| + back.className = "ln-btn"; |
| + back.textContent = "βΉ Teams"; |
| + back.onclick = () => { |
| + openTeam = null; |
| + filterText = ""; |
| + activeIndex = 0; |
| + redraw(); |
| + }; |
| + title.textContent = `${openTeam.name} (${openTeam.key})`; |
| + const newIssue = document.createElement("button"); |
| + newIssue.type = "button"; |
| + newIssue.className = "ln-btn"; |
| + newIssue.textContent = "οΌ New issue"; |
| + const refresh = document.createElement("button"); |
| + refresh.type = "button"; |
| + refresh.className = "ln-btn"; |
| + refresh.textContent = "β»"; |
| + refresh.setAttribute("aria-label", "Refresh issues"); |
| + refresh.onclick = () => fetchIssues(openTeam.key); |
| + head.append(back, title, sub, newIssue, refresh); |
| + |
| + const form = buildIssueForm(); |
| + newIssue.onclick = () => { |
| + form.hidden = !form.hidden; |
| + if (!form.hidden) form.querySelector("input").focus(); |
| + }; |
| + el.append(form, filter, listOrEmpty, note); |
| + |
| + drawList = drawIssues; |
| + } else { |
| + title.textContent = "Your teams"; |
| + const refresh = document.createElement("button"); |
| + refresh.type = "button"; |
| + refresh.className = "ln-btn"; |
| + refresh.textContent = "β» Refresh"; |
| + refresh.onclick = fetchTeams; |
| + head.append(title, sub, refresh); |
| + el.append(filter, listOrEmpty, note); |
| + |
| + drawList = drawTeams; |
| + } |
| + |
| + // Transient bits (busy label, count line) live outside folded state. |
| + const paint = () => { |
| + sub.textContent = busy |
| + ? "loadingβ¦" |
| + : openTeam |
| + ? (issues[openTeam.key] ? `${issues[openTeam.key].items.length} active` : "") |
| + : teams.length |
| + ? `${teams.length} team${teams.length === 1 ? "" : "s"}` |
| + : ""; |
| + }; |
| + |
| + drawList(); |
| + paint(); |
| + |
| + // Auto-loads kick off ONLY after the surface is fully built (the fetchers |
| + // call paint() β firing them mid-render is the GitHub app's TDZ bug). |
| + if (openTeam) { |
| + const entry = issues[openTeam.key]; |
| + // Opened with nothing (or stale > 2 min) β pull once, automatically. |
| + if (!busy && (!entry || Date.now() - (entry.fetchedAt || 0) > 120000)) fetchIssues(openTeam.key); |
| + } else if (!state.fetchedAt && !busy && !autoLoading) { |
| + autoLoading = true; |
| + fetchTeams(); |
| + } |
| + }, |
| +}); |