// 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: "📐", mark: "linear", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) places: ["you"], // 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(); } }, });