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