eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
17import { 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.
22const 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
31const 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.
45function 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).
58const MAX_ISSUE_TEAMS = 6;
59function 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.
70let openTeam = null; // { id, key, name }
71let filterText = "";
72let busy = false;
73let autoLoading = false;
74
75export 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