eleven / ocho Public

web: Linear companion app β€” teams, active issues, create-an-issue (#828)

The GitHub app's twin for Linear (requiresService: linear): your teams,
drill-in to a team's active issues (state/assignee/priority, rows linking
to linear.app), filter + keyboard nav, and a new-issue form whose
submission is the consent for that one write (issue.create rides the
team's UUID from teams.list β€” never typed). Same trust shape: every fetch
goes through requestOp's sealed one-op grants, results bounded by the
manifest's pick projection, no new bot, no server changes. Fold + render
regression tests mirror the GitHub app's (incl. the busy-wedge TDZ class).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 43d03cc8999b4fdf8720d8e04da263073cdc63b7 parent 1586e1d
7 files changed, +780 −3
  • changelog/2026-07-13-linear-app.md +7 −0
  • internal/bot/knowledge.md +3 −2
  • package.json +1 −1
  • test/linear-render.test.mjs +106 −0
  • test/linear.test.mjs +84 −0
  • web/static/apps/linear.js +578 −0
  • web/static/shell.js +1 −0
diff --git a/changelog/2026-07-13-linear-app.md b/changelog/2026-07-13-linear-app.md
new file mode 100644
index 0000000..091ed39
--- /dev/null
+++ b/changelog/2026-07-13-linear-app.md
@@ -0,0 +1,7 @@
+πŸ€– Linear gets its own app. Once you've connected Linear in Integrations, a
+Linear app appears under οΌ‹ New chat: your teams at a glance, any team's
+active issues a tap away (state, assignee, priority, each linking straight
+to linear.app), a filter box with keyboard navigation, and a form to create
+an issue right there β€” your submission is the consent for that one write.
+Like the GitHub app, everything travels end-to-end encrypted; the server
+only ever relays sealed data it cannot read.
diff --git a/internal/bot/knowledge.md b/internal/bot/knowledge.md
index 20f9733..5c7a85e 100644
--- a/internal/bot/knowledge.md
+++ b/internal/bot/knowledge.md
@@ -484,8 +484,9 @@ A personal app-thread is presented one of two ways, and **the app chooses**:
by pasting a scoped token (Linear connects with a personal API key). Either
way the key is locked to *you* β€” the
server stores a box only you can open. Once connected, a service can
- install its own app (GitHub's repositories and issues; DNSimple's domains),
- and the connected row's **Open** button goes straight there.
+ install its own app (GitHub's repositories and issues; Linear's teams and
+ issues; DNSimple's domains), and the connected row's **Open** button goes
+ straight there.
**How a bot acts on a connection:** you (Eleven) can *propose* an operation
β€” it appears as a **tappable chip** under your reply β€” but you can never
diff --git a/package.json b/package.json
index 915250e..4d31438 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/linear-render.test.mjs b/test/linear-render.test.mjs
new file mode 100644
index 0000000..b77fccb
--- /dev/null
+++ b/test/linear-render.test.mjs
@@ -0,0 +1,106 @@
+// Render smoke test for the Linear app β€” the same regression class the
+// GitHub app shipped (github-render.test.mjs): an auto-load firing mid-render
+// must neither throw nor wedge the module `busy` flag. Drives render twice
+// over a stub DOM and proves the fetch path both RUNS and RELEASES.
+// node test/linear-render.test.mjs
+import assert from "node:assert/strict";
+
+// A just-enough DOM: every element accepts children, classes, listeners.
+function fakeEl(tag = "div") {
+ return {
+ tagName: tag,
+ children: [],
+ textContent: "",
+ className: "",
+ hidden: false,
+ disabled: false,
+ value: "",
+ placeholder: "",
+ type: "",
+ onclick: null,
+ ondblclick: null,
+ appendChild(c) {
+ this.children.push(c);
+ return c;
+ },
+ append(...cs) {
+ this.children.push(...cs);
+ },
+ addEventListener() {},
+ querySelector() {
+ return fakeEl();
+ },
+ classList: { add() {}, remove() {}, toggle() {} },
+ focus() {},
+ scrollIntoView() {},
+ };
+}
+globalThis.document = {
+ createElement: (tag) => fakeEl(tag),
+ getElementById: () => null, // injectStyles' dedupe check
+ head: fakeEl("head"),
+ activeElement: null,
+ body: null,
+};
+globalThis.window = globalThis;
+
+const { app } = await import("../web/static/apps/linear.js");
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} β€” ${e.message.split("\n")[0]}`);
+ }
+}
+
+const tick = () => new Promise((r) => setTimeout(r, 0));
+
+function ctxWith(requestOp) {
+ const state = { teams: [], fetchedAt: 0, issues: {} };
+ return {
+ threadId: "t1",
+ host: { connected: { requestOp } },
+ getState: () => state,
+ emit: () => {},
+ onCleanup: () => {},
+ id: () => "id",
+ };
+}
+
+await test("render survives the auto-load and RELEASES busy for the next fetch", async () => {
+ let calls = 0;
+ const ctx = ctxWith(async () => {
+ calls++;
+ return { ok: false, error: "stub" };
+ });
+ app.render(undefined, fakeEl(), ctx);
+ await tick();
+ await tick();
+ assert.equal(calls, 1, "auto-load ran");
+ app.render(undefined, fakeEl(), ctx);
+ await tick();
+ await tick();
+ assert.equal(calls, 2, "busy was released β€” the app can fetch again");
+});
+
+await test("a requestOp that THROWS still lands in the error path and releases", async () => {
+ let calls = 0;
+ const ctx = ctxWith(async () => {
+ calls++;
+ throw new Error("boom");
+ });
+ app.render(undefined, fakeEl(), ctx);
+ await tick();
+ await tick();
+ app.render(undefined, fakeEl(), ctx);
+ await tick();
+ await tick();
+ assert.equal(calls, 2, "a thrown fetch must not wedge the app");
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/linear.test.mjs b/test/linear.test.mjs
new file mode 100644
index 0000000..46d4461
--- /dev/null
+++ b/test/linear.test.mjs
@@ -0,0 +1,84 @@
+// Fold tests for the Linear companion app's reducers (pure, no DOM):
+// pick-projected rows normalise into plain fields (flattened dot-path keys
+// like "state.name" become plain ones), malformed rows drop, the per-team
+// issues map stays bounded. node test/linear.test.mjs
+import assert from "node:assert/strict";
+import { app } from "../web/static/apps/linear.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} β€” ${e.message.split("\n")[0]}`);
+ }
+}
+
+const initial = () => app.reduce(undefined, { type: "noop" }) || { teams: [], fetchedAt: 0, issues: {} };
+const fold = (state, event) => app.reduce(state, event);
+
+await test("linear.teams replaces the list and normalises projected rows", () => {
+ const s = fold(
+ { teams: [{ id: "old", key: "OLD", name: "gone" }], fetchedAt: 1, issues: {} },
+ {
+ type: "linear.teams",
+ ts: 42,
+ teams: [
+ { id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", key: "ENG", name: "Engineering" },
+ { id: "u2", key: "OPS" }, // sparse row survives; name falls back to the key
+ { key: "NOID" }, // no id β€” dropped (issue.create needs the UUID)
+ "not-an-object",
+ ],
+ },
+ );
+ assert.equal(s.teams.length, 2);
+ assert.deepEqual(s.teams[0], { id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", key: "ENG", name: "Engineering" });
+ assert.equal(s.teams[1].name, "OPS");
+ assert.equal(s.fetchedAt, 42);
+});
+
+await test("a malformed linear.teams folds to a no-op", () => {
+ const before = { teams: [{ id: "u1", key: "KEEP", name: "Keep" }], fetchedAt: 5, issues: {} };
+ assert.equal(fold(before, { type: "linear.teams", teams: "nope" }), before);
+});
+
+await test("linear.issues normalises flattened dot-path keys into plain fields", () => {
+ const s = fold(initial(), {
+ type: "linear.issues",
+ team: "ENG",
+ ts: 9,
+ issues: [
+ { identifier: "ENG-412", title: "Crash on login", url: "https://linear.app/x/issue/ENG-412", "state.name": "In Progress", "assignee.displayName": "Amy", priorityLabel: "Urgent", updatedAt: "2026-07-08T00:00:00Z" },
+ { identifier: "ENG-9", title: "Flicker" }, // sparse row keeps defaults
+ { title: "no identifier β€” dropped" },
+ ],
+ });
+ const items = s.issues["ENG"].items;
+ assert.equal(items.length, 2);
+ assert.equal(items[0].state, "In Progress");
+ assert.equal(items[0].assignee, "Amy");
+ assert.equal(items[0].priority, "Urgent");
+ assert.equal(items[1].state, "");
+ assert.equal(items[1].url, "");
+});
+
+await test("the issues map is trimmed to the most recently fetched teams", () => {
+ let s = initial();
+ for (let i = 0; i < 9; i++) {
+ s = fold(s, { type: "linear.issues", team: `T${i}`, ts: i + 1, issues: [{ identifier: `T${i}-1`, title: "t" }] });
+ }
+ const keys = Object.keys(s.issues);
+ assert.equal(keys.length, 6); // bounded β€” the snapshot can't balloon
+ assert.ok(!keys.includes("T0") && !keys.includes("T1") && !keys.includes("T2")); // oldest dropped
+ assert.ok(keys.includes("T8"));
+});
+
+await test("the app is gated on the linear connected service", () => {
+ assert.equal(app.requiresService, "linear");
+ assert.deepEqual([...app.types].sort(), ["linear.issues", "linear.teams"]);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/linear.js b/web/static/apps/linear.js
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();
+ }
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index ab82b7a..14f881c 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -127,6 +127,7 @@ import "/static/apps/dnsimple.js";
import "/static/apps/stripe.js";
import "/static/apps/hubspot.js";
import "/static/apps/github.js";
+import "/static/apps/linear.js";
import { openThreadView } from "/static/apps/views.js"; // views: bot-created data surfaces (docs/views.md); also registers the app
import "/static/apps/emoji.js"; // /emoji β€” the command-panel reference app
import "/static/apps/gif.js"; // /gif β€” Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)