From 2c1ad5b640aa3fa357597a9d85761e8d56123baf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 04:11:36 +0000
Subject: [PATCH] chore: publish from main
---
.github/plugin/marketplace.json | 2 +-
extensions/sentry-triage/components/card.mjs | 4 +-
extensions/sentry-triage/components/page.mjs | 112 ++++++++++++--
extensions/sentry-triage/extension.mjs | 146 ++++++++++++++++--
extensions/sentry-triage/package.json | 2 +-
extensions/sentry-triage/sentry.mjs | 75 +++++----
extensions/sentry-triage/sentryClient.mjs | 50 ++++--
extensions/sentry-triage/server.mjs | 12 ++
extensions/sentry-triage/state.mjs | 65 +++++++-
.../sentry-triage/components/card.mjs | 4 +-
.../sentry-triage/components/page.mjs | 112 ++++++++++++--
.../extensions/sentry-triage/extension.mjs | 146 ++++++++++++++++--
.../extensions/sentry-triage/package.json | 2 +-
.../extensions/sentry-triage/sentry.mjs | 75 +++++----
.../extensions/sentry-triage/sentryClient.mjs | 50 ++++--
.../extensions/sentry-triage/server.mjs | 12 ++
.../extensions/sentry-triage/state.mjs | 65 +++++++-
plugins/sentry-triage/plugin.json | 2 +-
18 files changed, 770 insertions(+), 166 deletions(-)
diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index 8677354b..6c0efd27 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -1515,7 +1515,7 @@
"name": "sentry-triage",
"source": "plugins/sentry-triage",
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
- "version": "1.1.0"
+ "version": "1.2.0"
},
{
"name": "signals-dashboard",
diff --git a/extensions/sentry-triage/components/card.mjs b/extensions/sentry-triage/components/card.mjs
index 4ad20d12..b8c9259f 100644
--- a/extensions/sentry-triage/components/card.mjs
+++ b/extensions/sentry-triage/components/card.mjs
@@ -2,7 +2,9 @@ import { escapeHtml, safeHref } from '../escape.mjs'
function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
- if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…'
+ if (workStatus.phase === 'working' || workStatus.phase === 'queued') {
+ return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…'
+ }
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
diff --git a/extensions/sentry-triage/components/page.mjs b/extensions/sentry-triage/components/page.mjs
index 46460a66..0f358904 100644
--- a/extensions/sentry-triage/components/page.mjs
+++ b/extensions/sentry-triage/components/page.mjs
@@ -48,6 +48,7 @@ export function Page({
prTargets,
prSettingsOpen,
plainEnglishView = false,
+ plainEnglishEnriching = false,
projects = [],
availableModels = [],
issueTrackers,
@@ -313,14 +314,14 @@ export function Page({
-
+
-
+
- Plain-English titles
+ Plain-English messages
- Showing ${plainEnglishView ? 'plain-English summaries' : 'raw errors'}
+ ${plainEnglishEnriching ? 'Preparing plain-English summaries — toggle available shortly… ' : `Showing ${plainEnglishView ? 'plain-English summaries' : 'raw errors'} `}
@@ -406,6 +407,10 @@ export function Page({
// is already running for a slug, later callers (e.g. the Scan/Fetch gate)
// queue here and are flushed when it settles, instead of being dropped.
const projectResolveWaiters = {};
+ // Ceiling for a single exact-slug lookup before we give up and report a
+ // transient failure. Generous enough to absorb a normal queue wait behind
+ // an in-progress scan, short enough that the user is never stranded.
+ const RESOLVE_TIMEOUT_MS = 20000;
function projectResolveKey(org, slug) {
return String(org || "").trim().toLowerCase() + "/" + String(slug || "").trim().toLowerCase();
}
@@ -431,6 +436,10 @@ export function Page({
// An empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
+ // "stale" means the user changed the org/project while the lookup was
+ // in flight — they've already moved on, so don't scan the old scope
+ // and don't nag them about a slug they abandoned.
+ if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -699,6 +708,7 @@ export function Page({
let lastAutoFetchedOrgDefault = "";
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
+ let currentPlainEnglishEnriching = ${jsonForScript(plainEnglishEnriching)};
let currentScanError = ${jsonForScript(scanError || '')};
let currentScannedTotal = ${jsonForScript(scannedTotal)};
let currentScannedCapped = ${jsonForScript(scannedCapped)};
@@ -741,7 +751,7 @@ export function Page({
function statusText(status) {
if (!status || typeof status !== "object") return "";
- if (status.phase === "queued" || status.phase === "working") return "⏳ working…";
+ if (status.phase === "queued" || status.phase === "working") return status.copilotFix ? "⏳ starting Copilot fix session…" : "⏳ filing tracking issue…";
if (status.phase === "skipped") return "🔒 already being worked on";
if (status.phase === "tracked") return "👀 Tracked";
if (status.phase === "done") {
@@ -1089,17 +1099,34 @@ export function Page({
if (psel && psel.value !== msg.period) psel.value = msg.period;
}
if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
+ if (typeof msg.plainEnglishEnriching === "boolean" && msg.plainEnglishEnriching !== currentPlainEnglishEnriching) {
+ currentPlainEnglishEnriching = msg.plainEnglishEnriching;
+ syncTitleSwitch();
+ }
if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
+ const projectsComplete = msg.projectsComplete === true;
+ // Keep-longest ONLY for streamed/incomplete snapshots. The server
+ // streams partial pages (each broadcast is a growing snapshot), and a
+ // discovery run that fails or is cut short mid-traversal publishes a
+ // truncated snapshot for an org we already have fully loaded — the
+ // dropdown "sometimes comes back much shorter" symptom. But a COMPLETE
+ // traversal is authoritative and may legitimately be shorter (projects
+ // deleted upstream), so it must be allowed to replace the cache —
+ // otherwise deleted slugs would linger in autocomplete for the panel's
+ // whole life. Growth within a run still lands normally.
+ const known = forOrg ? projectsByOrg[forOrg] : null;
+ const regression = !projectsComplete && Array.isArray(known) && known.length > msg.projects.length;
+ const projects = regression ? known : msg.projects;
// Always cache under the org this list belongs to so re-selecting it is
// instant next time.
- if (forOrg) projectsByOrg[forOrg] = msg.projects;
+ if (forOrg && !regression) projectsByOrg[forOrg] = msg.projects;
// Only paint the on-screen field if this broadcast matches the org the
// user currently has selected — a background refresh for a previous org
// must not clobber the current list.
const sel = selectedOrgSlug();
if (!forOrg || !sel || forOrg === sel) {
- currentSentryProjects = msg.projects;
+ currentSentryProjects = projects;
setProjectLoading(false);
// Repaint whichever project field is on screen with the new options.
if (currentOrg) renderProjectSwitcher();
@@ -1129,7 +1156,20 @@ export function Page({
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
}
- if (Array.isArray(msg.availableModels)) currentAvailableModels = msg.availableModels;
+ if (Array.isArray(msg.availableModels)) {
+ currentAvailableModels = msg.availableModels;
+ // The host's model catalog can change under us (models ship or retire).
+ // Drop any per-card override whose id is no longer offered: otherwise the
+ // falls back to rendering "Default" while modelByKey still holds
+ // the dead id, counts it as an override, and POSTs it — where the server
+ // silently discards it. Prune, then re-sync the card selects.
+ const validModelIds = new Set(currentAvailableModels.map((m) => m && m.id).filter(Boolean));
+ let prunedModelOverride = false;
+ for (const k of Object.keys(modelByKey)) {
+ if (!validModelIds.has(modelByKey[k])) { delete modelByKey[k]; prunedModelOverride = true; }
+ }
+ if (prunedModelOverride) syncCardModels();
+ }
if (typeof msg.plainEnglishView === "boolean" && msg.plainEnglishView !== currentPlainEnglishView) {
currentPlainEnglishView = msg.plainEnglishView;
syncTitleSwitch();
@@ -1397,8 +1437,15 @@ export function Page({
}
projectResolveCache[key] = { status: "checking", slug: "" };
// Record the settled entry, notify this caller, then flush anyone who
- // queued while the request was in flight.
+ // queued while the request was in flight. Idempotent: whichever of the
+ // response and the timeout below fires first wins, and the loser is a
+ // no-op (so a late response can't overwrite the timeout's "error" and
+ // silently re-arm a lookup the user was already told had failed).
+ let settled = false;
const settle = (entry) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
projectResolveCache[key] = entry;
if (onDone) onDone(entry);
const waiters = projectResolveWaiters[key];
@@ -1407,6 +1454,13 @@ export function Page({
for (const fn of waiters) { try { fn(entry); } catch (_) {} }
}
};
+ // Hard ceiling on a single lookup. The server side runs on a shared,
+ // strictly-serial Sentry SDK queue, so this request can end up waiting
+ // behind other work; without a bound the UI would sit on "Checking
+ // Sentry…" indefinitely with no way out. Settling as a transient "error"
+ // (not "missing") keeps the honest distinction — the user gets a
+ // "couldn't check — press Enter to retry" they can act on.
+ const timer = setTimeout(() => settle({ status: "error", slug: "" }), RESOLVE_TIMEOUT_MS);
fetch("/api/resolve-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1430,6 +1484,14 @@ export function Page({
// project is an all-projects scan (always allowed); a locally-known project
// needs no lookup; anything else is verified via the shared resolver, reusing
// its cache and in-flight de-duplication.
+ //
+ // Resolves { ok: false, reason: "stale" } if the user changed the org or
+ // project while the lookup was in flight. A slow lookup (it queues behind
+ // whatever else is using the shared Sentry SDK chain) can settle long after
+ // the user has moved on; without this guard its callback would go on to
+ // overwrite the project input and scan the OLD slug — the "I selected ace
+ // but it scanned github-app" bug. The autocomplete's Enter path has always
+ // had this guard; this brings the Scan/Fetch path in line.
function verifyProjectForScan(org, project) {
return new Promise((resolve) => {
const o = String(org || "").trim().toLowerCase();
@@ -1446,7 +1508,15 @@ export function Page({
if (rc && rc.status === "found") { resolve({ ok: true, slug: rc.slug || raw }); return; }
if (rc && rc.status === "missing") { resolve({ ok: false, reason: "missing", slug: raw }); return; }
if (rc && rc.status === "error") delete projectResolveCache[key];
+ // Snapshot the scope this lookup was started for, so a late completion
+ // can be discarded rather than applied to a scope the user has left.
+ const scopeStillCurrent = () => {
+ const inp = activeProjectInput();
+ const nowProject = inp ? inp.value.trim().toLowerCase() : p;
+ return selectedOrgSlug() === o && nowProject === p;
+ };
resolveProjectSlug(o, p, (res) => {
+ if (!scopeStillCurrent()) { resolve({ ok: false, reason: "stale", slug: raw }); return; }
if (res && res.status === "found") resolve({ ok: true, slug: res.slug || raw });
else resolve({ ok: false, reason: (res && res.status) || "error", slug: raw });
});
@@ -1843,9 +1913,16 @@ export function Page({
// issue list; this keeps the toggle in sync with state (checkbox + hint).
function syncTitleSwitch() {
const box = document.getElementById("title-mode-switch");
- if (box) box.checked = currentPlainEnglishView;
- const state = document.getElementById("title-mode-state");
- if (state) state.textContent = currentPlainEnglishView ? "plain-English summaries" : "raw errors";
+ if (box) {
+ box.checked = currentPlainEnglishView;
+ box.disabled = currentPlainEnglishEnriching;
+ }
+ const hint = document.querySelector(".title-mode-hint");
+ if (hint) {
+ hint.innerHTML = currentPlainEnglishEnriching
+ ? 'Preparing plain-English summaries — toggle available shortly… '
+ : 'Showing ' + (currentPlainEnglishView ? "plain-English summaries" : "raw errors") + ' ';
+ }
}
// Flip the whole canvas between raw error and plain-English titles. Optimistic:
@@ -1854,6 +1931,7 @@ export function Page({
document.addEventListener("change", (e) => {
const box = e.target.closest("#title-mode-switch");
if (!box) return;
+ if (currentPlainEnglishEnriching) return;
currentPlainEnglishView = box.checked;
syncTitleSwitch();
renderCategories(currentCategories);
@@ -1878,6 +1956,10 @@ export function Page({
// empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
+ // "stale" means the user changed the org/project while the lookup was
+ // in flight — they've already moved on, so don't scan the old scope
+ // and don't nag them about a slug they abandoned.
+ if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -2167,7 +2249,7 @@ export function Page({
const keys = startableSelectedKeys();
if (keys.length === 0) return;
keys.forEach((key) => {
- workByIssue[key] = { phase: "queued" };
+ workByIssue[key] = { phase: "queued", copilotFix: false };
selectedKeys.delete(key);
});
applySelections();
@@ -2184,12 +2266,12 @@ export function Page({
const models = {};
keys.forEach((key) => {
if (modelByKey[key]) models[key] = modelByKey[key];
- workByIssue[key] = { phase: "queued" };
+ workByIssue[key] = { phase: "queued", copilotFix: true };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
- showToast("🔧 Creating/reusing issues and starting Copilot for " + keys.length + " issues…");
+ showToast("🔧 Starting a background Copilot session for " + keys.length + " issue" + (keys.length === 1 ? "" : "s") + " — you can keep triaging while it runs…");
postWorkSelected(keys, { keys, modelByKey: models, assignCopilot: true });
});
diff --git a/extensions/sentry-triage/extension.mjs b/extensions/sentry-triage/extension.mjs
index a6553594..3abdf09a 100644
--- a/extensions/sentry-triage/extension.mjs
+++ b/extensions/sentry-triage/extension.mjs
@@ -205,6 +205,35 @@ function safeIssueKey(value) {
const servers = new Map()
+// Live model catalog fetched from the host (session.rpc.model.list()), cached
+// here so a newly opened panel gets it immediately instead of waiting on
+// another RPC round trip. Stays null until the first successful fetch; every
+// server keeps its state.mjs static fallback list until then.
+let liveModels = null
+
+// Fetches the host's current model catalog and pushes it into every open
+// panel's state (plus caches it for panels opened afterward), so the picker
+// tracks whatever models the host makes available without editing this
+// extension's static list every time a model ships or is retired. Best-effort:
+// on any failure (older host, RPC error) every panel just keeps using the
+// static fallback list already seeded in state.mjs.
+async function refreshAvailableModels() {
+ try {
+ const result = await session.rpc.model.list()
+ const models = (result?.list || [])
+ .map((m) => ({ id: String(m?.id ?? ''), label: String(m?.name ?? m?.id ?? '') }))
+ .filter((m) => m.id)
+ if (models.length === 0) return
+ liveModels = models
+ for (const entry of servers.values()) {
+ entry.state.setAvailableModels(models)
+ entry.notifyClients()
+ }
+ } catch (err) {
+ console.error('[sentry-triage] could not fetch live model list, using static fallback:', err?.message || err)
+ }
+}
+
// Maps an opaque per-work token -> { entry, key } for a "Work on selected"
// hand-off. The spawned fix session echoes this token back via `submit_work_pr`
// so the PR update lands on the EXACT canvas instance and issue that started the
@@ -739,6 +768,20 @@ OR
{"status":"error","error":"plain explanation","issue":{"number":123,"url":"https://..."},"session":{"id":null,"name":""}}`
}
+// Reset the plain-English enriching flag when an in-flight enrichment is
+// invalidated (scan generation bumped) by a path that will NOT start a
+// replacement scan to clear it. triageSentry's isCurrent() guard deliberately
+// stops a STALE scan from clearing a NEWER scan's flag; the side effect is that
+// an invalidation with no successor (a refresh that finds Sentry unreachable, or
+// a repo switch via onInvalidateEnrichment) would otherwise strand the flag
+// `true` forever and leave the plain-English toggle disabled. Clearing it here
+// is safe: a reachable scan's triageSentry re-sets it true before it drops the
+// scanning overlay, so the toggle is never interactive with a false flag.
+function resetEnrichingFlag(entry) {
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
+}
+
async function triageSentry(entry) {
if (entry.closed) return
const org = entry.state.getOrg()
@@ -788,8 +831,16 @@ async function triageSentry(entry) {
// 240s; blocking the whole board on it would leave the user staring at a
// spinner. The card renderer already falls back to the raw title when
// `plainEnglish` is absent, so an early render is fully usable.
+ // Set the enriching flag BEFORE publishing the categories snapshot so that
+ // snapshot already reports enrichment as in-progress. Publishing categories
+ // first would emit a snapshot saying enrichment is idle (re-enabling the
+ // toggle), and a user action slipping in before the separate
+ // plainEnglishEnriching:true event preserves a narrow version of the race
+ // this flag exists to remove.
+ entry.state.setPlainEnglishEnriching(true)
entry.state.setCategories(categories)
entry.notifyClients()
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(true)
// End the scanning overlay NOW that the board is published — enrichment is
// optional polish (plain-English titles + tracked badges) that streams in via
// a later publish. Leaving the overlay up for the whole ≤240s enrich turn
@@ -805,7 +856,14 @@ async function triageSentry(entry) {
// so a stale turn (a newer scan started meanwhile) can't apply its inbox data
// over the newer scan's state.
await enrichPlainEnglish(entry, categories, isCurrent)
+ // A stale turn (a newer scan started during our ≤240s enrich) must NOT clear
+ // or broadcast the shared enriching flag: doing so would re-enable the toggle
+ // while the newer scan is still enriching, recreating the exact race this
+ // flag prevents. Bail before touching it — the current scan owns the flag and
+ // clears it when IT finishes (and the guarded `finally` is the fallback).
if (!isCurrent()) return
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
entry.state.setCategories(categories)
entry.notifyClients()
console.error('[sentry-triage] Scan complete, categories:', categories.length, error ? `error: ${error}` : '')
@@ -821,6 +879,10 @@ async function triageSentry(entry) {
entry.notifyClients()
}
} finally {
+ if (isCurrent()) {
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
+ }
if (isCurrent() && entry.notifyScanning) entry.notifyScanning(false)
}
}
@@ -915,7 +977,7 @@ After the tool call(s), reply to the user with a confirmation sentence, then a b
Triaged ${total} Sentry ${noun}.
-**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default — flip the **Plain-English titles** switch above the issue list for readable summaries. Open the ⚙️ Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
+**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default — flip the **Plain-English messages** switch above the issue list for readable summaries. Open the ⚙️ Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
Keep it friendly and brief; do not include the summaries or JSON.
@@ -1134,19 +1196,62 @@ async function discoverProjects(entry, org, { force = false } = {}) {
const conn = entry.state.getConnections()
if (!conn || !conn.sentry || !conn.sentry.reachable) return
if (!force && entry._projectsFetchedFor === slug) return
+ // Each page now releases the shared SDK queue, so discoveries for different
+ // orgs (A then B) can interleave. Stamp this discovery with a generation
+ // token; a newer discoverProjects call bumps it, and every publish below
+ // (streamed partial, final store, catch path) bails once superseded — so a
+ // slow org-A traversal can't write A's slugs into the snapshot after org B
+ // has taken over (which would leave org === B but projectsOrg === A).
+ const myGen = (entry._projectsGen = (entry._projectsGen || 0) + 1)
+ const isCurrentGen = () => entry._projectsGen === myGen && !entry.closed
try {
+ // Seed the keep-longest guard from any list a PRIOR run already published for
+ // this org. The streamed partials below feed newly connected clients (they
+ // read this server snapshot), so an early page of a retry must not overwrite
+ // a longer prior result — the final-store guard alone can't protect a client
+ // that connects mid-stream.
+ const priorKnown = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
+ let storedLen = Array.isArray(priorKnown) ? priorKnown.length : 0
// Stream results: push each page to the panel as it arrives so the first
// ~100 projects light up the autocomplete in ~1s while the rest fill in.
- const projects = await listProjects(slug, (partial) => {
- entry.state.setProjects(partial, slug)
+ const { projects, complete } = await listProjects(slug, (partial) => {
+ // A newer discovery has superseded this one — drop the partial rather than
+ // publishing a stale org's slugs into the shared snapshot.
+ if (!isCurrentGen()) return
+ // Only store a partial that GROWS the snapshot beyond what's already
+ // published for this org. Within one traversal partials grow monotonically;
+ // this guard is what stops a retry's small first page from shrinking a
+ // longer prior list for clients that connect between pages.
+ if (!Array.isArray(partial) || partial.length <= storedLen) return
+ storedLen = partial.length
+ entry.state.setProjects(partial, slug, false)
entry.notifyClients()
})
- entry._projectsFetchedFor = slug
- entry.state.setProjects(projects, slug)
+ // Superseded while we were traversing: skip the final store (and the cache
+ // marker) so the winning discovery owns the snapshot.
+ if (!isCurrentGen()) return
+ // A COMPLETE traversal is authoritative: publish it as-is even if it's
+ // shorter than a prior run, so projects deleted upstream actually leave the
+ // dropdown. Only an INCOMPLETE traversal (a mid-mega-org page stall/failure)
+ // must be prevented from shrinking a longer list we already have — the
+ // "sometimes the list is much shorter" symptom. The finality flag rides
+ // along in the snapshot so the client applies the same rule.
+ const known = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
+ const best = complete
+ ? projects
+ : (Array.isArray(known) && known.length > projects.length ? known : projects)
+ // Only treat the list as definitively cached when the traversal actually
+ // ran to completion. A truncated run must stay retryable, otherwise the
+ // first unlucky attempt pins a partial list for the life of the panel.
+ if (complete) entry._projectsFetchedFor = slug
+ entry.state.setProjects(best, slug, complete)
entry.notifyClients()
- console.error('[sentry-triage] discovered projects for', slug, '->', projects.length)
+ console.error('[sentry-triage] discovered projects for', slug, '->', best.length)
} catch (err) {
console.error('[sentry-triage] project discovery failed:', err instanceof Error ? err.message : err)
+ // Superseded by a newer discovery — its own publish owns the snapshot; don't
+ // clobber it with this stale org's partial/empty list.
+ if (!isCurrentGen()) return
// The client already received a 200 from POST /api/list-projects (that
// request just kicks off this async fetch), so its own catch never runs and
// the autocomplete's loading indicator would pulse forever. Push a project
@@ -1155,7 +1260,7 @@ async function discoverProjects(entry, org, { force = false } = {}) {
// set `_projectsFetchedFor` here, so a later navigation or force refresh can
// retry a transient failure instead of caching the empty result.
const partial = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
- entry.state.setProjects(partial, slug)
+ entry.state.setProjects(partial, slug, false)
entry.notifyClients()
}
}
@@ -1236,6 +1341,11 @@ async function refreshAll(entry) {
// generation up front fails their isCurrent() guard immediately. triageSentry()
// bumps it again when it runs, which is fine.
entry.scanGen = (entry.scanGen || 0) + 1
+ // The bump above invalidated any enrichment still in flight. Clear its flag now
+ // so a path that never runs a replacement triageSentry (the Sentry-unreachable
+ // return below) can't leave the plain-English toggle disabled forever. A
+ // reachable scan re-sets it true before dropping the overlay.
+ resetEnrichingFlag(entry)
// Claim an ordering token for THIS refresh. The refresh endpoint is fire-and-
// forget, so two rapid refresh/period/project actions can resolve out of order:
// a slower, older refresh could publish stale connection state, clear the newer
@@ -1460,7 +1570,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// error) before re-queuing, so stale issue/PR/session links don't survive the
// shallow-merge into the new run's status.
entry.state.startWorkAttempt(key)
- entry.notifyWork(key, { phase: 'queued' })
+ entry.notifyWork(key, { phase: 'queued', copilotFix: wantsCopilot })
}
// On a timeout we must `return` (can't pile a new turn onto the shared session
@@ -1500,7 +1610,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
const workToken = randomUUID()
workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: prExpectedRepo, authorizedHost: allowedHost })
- entry.notifyWork(key, { phase: 'working', error: '' })
+ entry.notifyWork(key, { phase: 'working', error: '', copilotFix: wantsCopilot })
try {
const response = await runSessionTurn(() => {
// This closure runs only when the shared-session chain drains to it,
@@ -1665,7 +1775,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// retry can't race the live session into a duplicate. Otherwise there's no
// session to wait on — release the token and surface a retryable error.
if (handedOff) {
- entry.notifyWork(key, { phase: 'working' })
+ entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
continue
}
@@ -1776,7 +1886,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// session and then dies without spawning, no callback ever arrives; the
// bounded scheduleWorkReconcile below is the safety net that eventually
// releases the token and returns the card to a retryable error.
- entry.notifyWork(key, { phase: 'working' })
+ entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
strandRemaining(i + 1)
return
@@ -2040,10 +2150,19 @@ const session = await joinSession({
// from the previous repo fails its isCurrent() guard and applies
// none of its (now stale) tracking / related-issue data.
entry.scanGen = (entry.scanGen || 0) + 1
+ // No replacement scan runs on a repo switch, so clear the enriching
+ // flag here too — otherwise the invalidated enrichment (barred from
+ // clearing it by its isCurrent() guard) would leave the toggle
+ // disabled forever.
+ resetEnrichingFlag(entry)
},
defaults: runtimeDefaults,
})
servers.set(ctx.instanceId, entry)
+ // Apply whatever live catalog we already fetched (best-effort; the
+ // static fallback in state.mjs stands until refreshAvailableModels
+ // resolves for the first time).
+ if (liveModels) entry.state.setAvailableModels(liveModels)
} else if (runtimeDefaults.repo) {
// Panel already existed (e.g. opened before the repo resolved) — top
// up its targets now and push the update to any connected clients.
@@ -2094,3 +2213,8 @@ const session = await joinSession({
// Now that we're joined to the host, resolve the driving session's real repo so
// the PR/Issue targets default correctly (must run before any canvas opens).
await seedDefaultsFromSession()
+
+// Kick off the live model catalog fetch in the background; the static list in
+// state.mjs stands until it resolves, so panels are usable immediately either
+// way. Not awaited: model.list() is best-effort and shouldn't delay startup.
+refreshAvailableModels()
diff --git a/extensions/sentry-triage/package.json b/extensions/sentry-triage/package.json
index f98cb2f0..525046d6 100644
--- a/extensions/sentry-triage/package.json
+++ b/extensions/sentry-triage/package.json
@@ -1,6 +1,6 @@
{
"name": "sentry-triage",
- "version": "1.1.0",
+ "version": "1.2.0",
"main": "extension.mjs",
"author": "Liz Tom",
"license": "MIT",
diff --git a/extensions/sentry-triage/sentry.mjs b/extensions/sentry-triage/sentry.mjs
index b294ed47..fa0fc142 100644
--- a/extensions/sentry-triage/sentry.mjs
+++ b/extensions/sentry-triage/sentry.mjs
@@ -16,8 +16,7 @@ import {
orgList,
projectView,
issueList,
- projectListRaw,
- runSerial,
+ projectListPage,
SentryError,
} from './sentryClient.mjs'
@@ -221,52 +220,45 @@ export function categorize({ issues = [], regressed = new Set(), escalating = ne
}
// All project slugs in an org. Pages through the list (the API caps each page at
-// 100) so orgs with many projects are fully represented in the dropdown. The
-// loop is bounded and stops as soon as a page adds no new slugs, so it stays
-// safe even if the underlying cursor doesn't advance. Throws SentryError on an
-// auth/permission failure.
+// 100) so orgs with many projects are fully represented in the dropdown. Throws
+// SentryError on an auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
- // Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
- // the symbolic "next" cursor through global per-command state, so pages must not
- // interleave with each other or with any other SDK call (a concurrent issue
- // scan, another instance's discovery, a second traversal of this same org).
- // runSerial holds the module-wide queue for the whole loop, which guarantees
- // that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
- // the queue we already hold.
- return runSerial(() => listProjectsPaged(org, onPage))
-}
-
-async function listProjectsPaged(org, onPage) {
+ // Each page is its own runSerial task (see projectListPage), so a mega-org's
+ // traversal no longer holds the shared SDK queue end-to-end. That is what lets
+ // an interactive project.view lookup interleave and answer in ~1s instead of
+ // waiting out the whole list — the "Checking Sentry…" hang.
const seen = new Set()
const out = []
const PAGE = 100
- const MAX_PAGES = 20
- // Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
- // take minutes to fully page through, leaving the autocomplete spinning. Stop
- // once we've spent this long and return what we have — the client filters the
- // collected slugs, and a few hundred is plenty to type against. With streaming
- // (onPage) the first page is usable almost immediately regardless.
- const BUDGET_MS = 8000
- const started = Date.now()
+ // Bound the traversal by PAGES, not wall-clock. A time budget made the result
+ // depend on how fast the network happened to be, so the same org yielded a
+ // different-length list run to run (and a slow run could cache a truncated list
+ // over a previously complete one). A page cap is deterministic: the same org
+ // always yields the same list.
+ const MAX_PAGES = 60
let cursor
+ let complete = false
for (let page = 0; page < MAX_PAGES; page++) {
- let raw
+ let result
try {
- raw = await projectListRaw(org, PAGE, cursor)
+ result = await projectListPage(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
- // not discard the projects we already collected. Surface the error only
- // when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
+ // not discard the projects we already collected. Observed against a
+ // mega-org: the first pages return in ~300ms each and then a later page
+ // stalls before failing, so this path is routine, not exceptional. Surface
+ // the error only when we have nothing at all (e.g. page 0 failed => likely
+ // auth/bad org).
if (out.length) break
throw err
}
let added = 0
- for (const slug of mapProjects(raw)) {
+ for (const slug of mapProjects(result.projects)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
@@ -275,11 +267,26 @@ async function listProjectsPaged(org, onPage) {
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
- if (raw.length < PAGE || added === 0) break
- if (Date.now() - started > BUDGET_MS) break
- cursor = 'next'
+ // Terminal condition: the SDK's own envelope says there are no further
+ // pages. A short page is NOT authoritative on its own — the SDK can return
+ // fewer than requested and still report hasMore:true — so `hasMore` is the
+ // only signal that marks the traversal complete and cacheable. (When the
+ // envelope omits hasMore, projectListPage defaults it to false, which
+ // conservatively ends the traversal rather than looping forever.)
+ if (!result.hasMore) { complete = true; break }
+ // The SDK says more pages exist but handed back no usable cursor to fetch
+ // them, or the cursor did not advance (it returned the same token again).
+ // Either way we can't safely continue, and the traversal is NOT complete —
+ // leave `complete` false so the caller keeps it retryable rather than
+ // pinning a truncated list. Detect this by CURSOR progress, not record
+ // content: a page can legitimately add zero new slugs (all duplicates or
+ // filtered records) while still advancing its cursor, and stopping on
+ // `added === 0` would pin that boundary so every retry halts there and later
+ // projects never reach the dropdown.
+ if (!result.nextCursor || result.nextCursor === cursor) break
+ cursor = result.nextCursor
}
- return out
+ return { projects: out, complete }
}
// Verify a specific project slug exists / is accessible. The org's project list
diff --git a/extensions/sentry-triage/sentryClient.mjs b/extensions/sentry-triage/sentryClient.mjs
index 7cba248f..85939b5c 100644
--- a/extensions/sentry-triage/sentryClient.mjs
+++ b/extensions/sentry-triage/sentryClient.mjs
@@ -143,9 +143,11 @@ async function getSdk() {
//
// `runSerial(task)` runs `task` only once all previously enqueued work has
// settled, so at most one SDK operation is ever in flight process-wide. It is the
-// single choke point; the public functions below are thin queued wrappers, and
-// multi-call traversals (see projectListRaw) run as ONE task so their paging
-// can't interleave with anything.
+// single choke point; the public functions below are thin queued wrappers. Each
+// task should be a SINGLE SDK call: a multi-call traversal that holds the queue
+// for its whole run starves every interactive lookup behind it (see
+// projectListPage, which pages via an explicit cursor so each page queues
+// independently).
let sdkQueue = Promise.resolve()
export function runSerial(task) {
@@ -279,17 +281,39 @@ export async function orgList(limit = 100) {
return runSerial(async () => asArray(await (await getSdk()).org.list({ limit })))
}
-// Single-page project fetch within an org. Deliberately NOT wrapped in
-// runSerial on its own: the CLI's positional org/project value treats a BARE
-// slug as a *project*, so listing every project in an org requires the trailing
-// `/` form — we normalize to exactly one trailing slash here. Raw project
-// objects (each has a `slug`). `cursor` navigates pages ("next"/"prev"/raw
-// cursor). Callers that page through the full list must instead run
-// projectListRaw inside a single runSerial task so the whole traversal is atomic
-// (see listProjects in sentry.mjs).
-export async function projectListRaw(org, limit = 100, cursor) {
+// Single page of an org's project list, fetched as its OWN queued task and
+// returning an explicit `nextCursor` so the caller can resume without holding
+// the queue between pages.
+//
+// This is what keeps a mega-org's multi-page traversal from starving the rest of
+// the UI. The symbolic "next" cursor resolves through the SDK's module-global
+// per-command state, so a traversal that used it had to hold the whole sdkQueue
+// for every page (otherwise an interleaved call would clobber that state) —
+// which meant a slow `github`-sized list blocked the O(1) project.view lookup
+// behind it for minutes and left "Checking Sentry…" spinning. The envelope's
+// `nextCursor` is a self-contained opaque token, so each page can be an
+// independent runSerial task and interactive lookups can slot in between them.
+//
+// The CLI's positional org/project value treats a BARE slug as a *project*, so
+// listing every project in an org requires the trailing `/` form — we
+// normalize to exactly one trailing slash here.
+//
+// Returns { projects, nextCursor, hasMore }. `hasMore` is the SDK envelope's own
+// "there are further pages" signal (defaults to false when the envelope omits
+// it); `nextCursor` is '' unless the SDK handed back a usable opaque token. The
+// two are reported separately so the caller can tell "genuinely done" (hasMore
+// false) apart from "more pages exist but we have no cursor to fetch them" — the
+// latter is an incomplete traversal, not a terminal one, and must stay retryable.
+export async function projectListPage(org, limit = 100, cursor) {
const orgProject = `${String(org || '').replace(/\/+$/, '')}/`
- return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
+ const res = await runSerial(async () => (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
+ const projects = asArray(res)
+ // Only trust an explicit opaque cursor. A truthy `hasMore` without a usable
+ // `nextCursor` would otherwise tempt us back onto the symbolic "next" token
+ // and reintroduce the global-state coupling this function exists to avoid.
+ const next = res && typeof res === 'object' && typeof res.nextCursor === 'string' ? res.nextCursor.trim() : ''
+ const hasMore = res && typeof res === 'object' ? res.hasMore === true : false
+ return { projects, nextCursor: hasMore ? next : '', hasMore }
}
// Verify a specific project exists / is accessible via the SDK's O(1)
diff --git a/extensions/sentry-triage/server.mjs b/extensions/sentry-triage/server.mjs
index d4aef00e..35461b81 100644
--- a/extensions/sentry-triage/server.mjs
+++ b/extensions/sentry-triage/server.mjs
@@ -108,11 +108,13 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
+ projectsComplete: state.getProjectsComplete(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
+ plainEnglishEnriching: state.getPlainEnglishEnriching(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
@@ -153,6 +155,15 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
}
}
+ // Broadcast plain-English enrichment start/stop so the client can disable the
+ // toggle (and show a "preparing…" hint) until summaries are ready.
+ function notifyPlainEnglishEnriching(isEnriching) {
+ const data = JSON.stringify({ plainEnglishEnriching: Boolean(isEnriching) })
+ for (const res of sseClients) {
+ res.write(`data: ${data}\n\n`)
+ }
+ }
+
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
@@ -546,6 +557,7 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
notifyWork,
notifyFlash,
notifyScanning,
+ notifyPlainEnglishEnriching,
close,
})
})
diff --git a/extensions/sentry-triage/state.mjs b/extensions/sentry-triage/state.mjs
index b1116b1b..7eb6fcfa 100644
--- a/extensions/sentry-triage/state.mjs
+++ b/extensions/sentry-triage/state.mjs
@@ -52,6 +52,12 @@ export function createState() {
// SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal).
let projectsOrg = ''
+ // Finality of the current `projects` list: true only when the traversal that
+ // produced it ran to completion (SDK reported no further pages). A COMPLETE
+ // list is authoritative and may legitimately be shorter than a prior one
+ // (projects deleted upstream); an INCOMPLETE/streamed one must not shrink a
+ // longer list. Consumed by the client's keep-longest guard via the snapshot.
+ let projectsComplete = false
// Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below).
@@ -68,6 +74,7 @@ export function createState() {
// Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly
// what Sentry reported first; the user can flip to plain English from the header.
let plainEnglishView = false
+ let plainEnglishEnriching = false
let selectedTracker = 'github'
const workByIssueKey = {}
// The issue trackers the "Work on selected" hand-off can file into. The agent
@@ -79,18 +86,27 @@ export function createState() {
{ id: 'linear', label: 'Linear', connected: true },
{ id: 'atlassian', label: 'Jira (Atlassian)', connected: true },
]
- // Models the spawned remediation session can run under. The empty id means
- // "let the session pick its default model"; every other id must match a value
- // the host create_session tool accepts, or the hand-off would fail.
- const availableModels = [
- { id: '', label: 'Auto (session default)' },
+ // Models the spawned remediation session can run under. The empty id always
+ // means "let the session pick its default model". The rest of the list is
+ // seeded with a static fallback (used until the live catalog loads, or if
+ // that fetch ever fails) and is normally replaced at startup by
+ // setAvailableModels() with the host's live model list (see extension.mjs,
+ // which calls session.rpc.model.list()) so this never needs to be hand-edited
+ // again when new models ship.
+ const AUTO_MODEL = { id: '', label: 'Auto (session default)' }
+ const FALLBACK_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
+ { id: 'claude-opus-5', label: 'Claude Opus 5' },
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
+ { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol' },
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' },
+ { id: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash' },
+ { id: 'grok-4.5', label: 'Grok 4.5' },
]
- const MODEL_IDS = new Set(availableModels.map((model) => model.id))
+ let availableModels = [AUTO_MODEL, ...FALLBACK_MODELS]
+ let MODEL_IDS = new Set(availableModels.map((model) => model.id))
const prTargets = {
mode: 'local',
model: '',
@@ -245,6 +261,10 @@ export function createState() {
return projectsOrg
},
+ getProjectsComplete() {
+ return projectsComplete
+ },
+
getConnections() {
return connections
},
@@ -261,6 +281,26 @@ export function createState() {
return availableModels
},
+ // Replaces the model list with the host's live catalog (see
+ // session.rpc.model.list() in extension.mjs). Keeps the current selection
+ // if it's still valid in the new list; otherwise falls back to Auto so a
+ // model retired upstream can't leave prTargets pointing at a dead id.
+ setAvailableModels(models) {
+ const seen = new Set([''])
+ const deduped = []
+ for (const m of Array.isArray(models) ? models : []) {
+ const id = typeof m?.id === 'string' ? m.id.trim() : ''
+ const label = typeof m?.label === 'string' && m.label.trim() ? m.label.trim() : id
+ if (!id || seen.has(id)) continue
+ seen.add(id)
+ deduped.push({ id, label })
+ }
+ if (deduped.length === 0) return
+ availableModels = [AUTO_MODEL, ...deduped]
+ MODEL_IDS = new Set(availableModels.map((model) => model.id))
+ if (prTargets.model && !MODEL_IDS.has(prTargets.model)) prTargets.model = ''
+ },
+
getIssueTrackers() {
return issueTrackers
},
@@ -289,7 +329,7 @@ export function createState() {
project = typeof slug === 'string' ? slug.trim() : ''
},
- setProjects(list, org) {
+ setProjects(list, org, complete = false) {
const seen = new Set()
const out = []
for (const item of Array.isArray(list) ? list : []) {
@@ -300,6 +340,7 @@ export function createState() {
}
projects = out
if (typeof org === 'string') projectsOrg = org.trim().toLowerCase()
+ projectsComplete = complete === true
return projects
},
@@ -346,7 +387,17 @@ export function createState() {
return plainEnglishView
},
+ getPlainEnglishEnriching() {
+ return plainEnglishEnriching
+ },
+
+ setPlainEnglishEnriching(on) {
+ plainEnglishEnriching = Boolean(on)
+ return plainEnglishEnriching
+ },
+
togglePlainEnglishView() {
+ if (plainEnglishEnriching) return plainEnglishView
plainEnglishView = !plainEnglishView
return plainEnglishView
},
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/card.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/card.mjs
index 4ad20d12..b8c9259f 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/card.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/card.mjs
@@ -2,7 +2,9 @@ import { escapeHtml, safeHref } from '../escape.mjs'
function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
- if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…'
+ if (workStatus.phase === 'working' || workStatus.phase === 'queued') {
+ return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…'
+ }
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/page.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/page.mjs
index 46460a66..0f358904 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/page.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/components/page.mjs
@@ -48,6 +48,7 @@ export function Page({
prTargets,
prSettingsOpen,
plainEnglishView = false,
+ plainEnglishEnriching = false,
projects = [],
availableModels = [],
issueTrackers,
@@ -313,14 +314,14 @@ export function Page({
-
+
-
+
- Plain-English titles
+ Plain-English messages
- Showing ${plainEnglishView ? 'plain-English summaries' : 'raw errors'}
+ ${plainEnglishEnriching ? 'Preparing plain-English summaries — toggle available shortly… ' : `Showing ${plainEnglishView ? 'plain-English summaries' : 'raw errors'} `}
@@ -406,6 +407,10 @@ export function Page({
// is already running for a slug, later callers (e.g. the Scan/Fetch gate)
// queue here and are flushed when it settles, instead of being dropped.
const projectResolveWaiters = {};
+ // Ceiling for a single exact-slug lookup before we give up and report a
+ // transient failure. Generous enough to absorb a normal queue wait behind
+ // an in-progress scan, short enough that the user is never stranded.
+ const RESOLVE_TIMEOUT_MS = 20000;
function projectResolveKey(org, slug) {
return String(org || "").trim().toLowerCase() + "/" + String(slug || "").trim().toLowerCase();
}
@@ -431,6 +436,10 @@ export function Page({
// An empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
+ // "stale" means the user changed the org/project while the lookup was
+ // in flight — they've already moved on, so don't scan the old scope
+ // and don't nag them about a slug they abandoned.
+ if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -699,6 +708,7 @@ export function Page({
let lastAutoFetchedOrgDefault = "";
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
+ let currentPlainEnglishEnriching = ${jsonForScript(plainEnglishEnriching)};
let currentScanError = ${jsonForScript(scanError || '')};
let currentScannedTotal = ${jsonForScript(scannedTotal)};
let currentScannedCapped = ${jsonForScript(scannedCapped)};
@@ -741,7 +751,7 @@ export function Page({
function statusText(status) {
if (!status || typeof status !== "object") return "";
- if (status.phase === "queued" || status.phase === "working") return "⏳ working…";
+ if (status.phase === "queued" || status.phase === "working") return status.copilotFix ? "⏳ starting Copilot fix session…" : "⏳ filing tracking issue…";
if (status.phase === "skipped") return "🔒 already being worked on";
if (status.phase === "tracked") return "👀 Tracked";
if (status.phase === "done") {
@@ -1089,17 +1099,34 @@ export function Page({
if (psel && psel.value !== msg.period) psel.value = msg.period;
}
if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
+ if (typeof msg.plainEnglishEnriching === "boolean" && msg.plainEnglishEnriching !== currentPlainEnglishEnriching) {
+ currentPlainEnglishEnriching = msg.plainEnglishEnriching;
+ syncTitleSwitch();
+ }
if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
+ const projectsComplete = msg.projectsComplete === true;
+ // Keep-longest ONLY for streamed/incomplete snapshots. The server
+ // streams partial pages (each broadcast is a growing snapshot), and a
+ // discovery run that fails or is cut short mid-traversal publishes a
+ // truncated snapshot for an org we already have fully loaded — the
+ // dropdown "sometimes comes back much shorter" symptom. But a COMPLETE
+ // traversal is authoritative and may legitimately be shorter (projects
+ // deleted upstream), so it must be allowed to replace the cache —
+ // otherwise deleted slugs would linger in autocomplete for the panel's
+ // whole life. Growth within a run still lands normally.
+ const known = forOrg ? projectsByOrg[forOrg] : null;
+ const regression = !projectsComplete && Array.isArray(known) && known.length > msg.projects.length;
+ const projects = regression ? known : msg.projects;
// Always cache under the org this list belongs to so re-selecting it is
// instant next time.
- if (forOrg) projectsByOrg[forOrg] = msg.projects;
+ if (forOrg && !regression) projectsByOrg[forOrg] = msg.projects;
// Only paint the on-screen field if this broadcast matches the org the
// user currently has selected — a background refresh for a previous org
// must not clobber the current list.
const sel = selectedOrgSlug();
if (!forOrg || !sel || forOrg === sel) {
- currentSentryProjects = msg.projects;
+ currentSentryProjects = projects;
setProjectLoading(false);
// Repaint whichever project field is on screen with the new options.
if (currentOrg) renderProjectSwitcher();
@@ -1129,7 +1156,20 @@ export function Page({
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
}
- if (Array.isArray(msg.availableModels)) currentAvailableModels = msg.availableModels;
+ if (Array.isArray(msg.availableModels)) {
+ currentAvailableModels = msg.availableModels;
+ // The host's model catalog can change under us (models ship or retire).
+ // Drop any per-card override whose id is no longer offered: otherwise the
+ // falls back to rendering "Default" while modelByKey still holds
+ // the dead id, counts it as an override, and POSTs it — where the server
+ // silently discards it. Prune, then re-sync the card selects.
+ const validModelIds = new Set(currentAvailableModels.map((m) => m && m.id).filter(Boolean));
+ let prunedModelOverride = false;
+ for (const k of Object.keys(modelByKey)) {
+ if (!validModelIds.has(modelByKey[k])) { delete modelByKey[k]; prunedModelOverride = true; }
+ }
+ if (prunedModelOverride) syncCardModels();
+ }
if (typeof msg.plainEnglishView === "boolean" && msg.plainEnglishView !== currentPlainEnglishView) {
currentPlainEnglishView = msg.plainEnglishView;
syncTitleSwitch();
@@ -1397,8 +1437,15 @@ export function Page({
}
projectResolveCache[key] = { status: "checking", slug: "" };
// Record the settled entry, notify this caller, then flush anyone who
- // queued while the request was in flight.
+ // queued while the request was in flight. Idempotent: whichever of the
+ // response and the timeout below fires first wins, and the loser is a
+ // no-op (so a late response can't overwrite the timeout's "error" and
+ // silently re-arm a lookup the user was already told had failed).
+ let settled = false;
const settle = (entry) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
projectResolveCache[key] = entry;
if (onDone) onDone(entry);
const waiters = projectResolveWaiters[key];
@@ -1407,6 +1454,13 @@ export function Page({
for (const fn of waiters) { try { fn(entry); } catch (_) {} }
}
};
+ // Hard ceiling on a single lookup. The server side runs on a shared,
+ // strictly-serial Sentry SDK queue, so this request can end up waiting
+ // behind other work; without a bound the UI would sit on "Checking
+ // Sentry…" indefinitely with no way out. Settling as a transient "error"
+ // (not "missing") keeps the honest distinction — the user gets a
+ // "couldn't check — press Enter to retry" they can act on.
+ const timer = setTimeout(() => settle({ status: "error", slug: "" }), RESOLVE_TIMEOUT_MS);
fetch("/api/resolve-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1430,6 +1484,14 @@ export function Page({
// project is an all-projects scan (always allowed); a locally-known project
// needs no lookup; anything else is verified via the shared resolver, reusing
// its cache and in-flight de-duplication.
+ //
+ // Resolves { ok: false, reason: "stale" } if the user changed the org or
+ // project while the lookup was in flight. A slow lookup (it queues behind
+ // whatever else is using the shared Sentry SDK chain) can settle long after
+ // the user has moved on; without this guard its callback would go on to
+ // overwrite the project input and scan the OLD slug — the "I selected ace
+ // but it scanned github-app" bug. The autocomplete's Enter path has always
+ // had this guard; this brings the Scan/Fetch path in line.
function verifyProjectForScan(org, project) {
return new Promise((resolve) => {
const o = String(org || "").trim().toLowerCase();
@@ -1446,7 +1508,15 @@ export function Page({
if (rc && rc.status === "found") { resolve({ ok: true, slug: rc.slug || raw }); return; }
if (rc && rc.status === "missing") { resolve({ ok: false, reason: "missing", slug: raw }); return; }
if (rc && rc.status === "error") delete projectResolveCache[key];
+ // Snapshot the scope this lookup was started for, so a late completion
+ // can be discarded rather than applied to a scope the user has left.
+ const scopeStillCurrent = () => {
+ const inp = activeProjectInput();
+ const nowProject = inp ? inp.value.trim().toLowerCase() : p;
+ return selectedOrgSlug() === o && nowProject === p;
+ };
resolveProjectSlug(o, p, (res) => {
+ if (!scopeStillCurrent()) { resolve({ ok: false, reason: "stale", slug: raw }); return; }
if (res && res.status === "found") resolve({ ok: true, slug: res.slug || raw });
else resolve({ ok: false, reason: (res && res.status) || "error", slug: raw });
});
@@ -1843,9 +1913,16 @@ export function Page({
// issue list; this keeps the toggle in sync with state (checkbox + hint).
function syncTitleSwitch() {
const box = document.getElementById("title-mode-switch");
- if (box) box.checked = currentPlainEnglishView;
- const state = document.getElementById("title-mode-state");
- if (state) state.textContent = currentPlainEnglishView ? "plain-English summaries" : "raw errors";
+ if (box) {
+ box.checked = currentPlainEnglishView;
+ box.disabled = currentPlainEnglishEnriching;
+ }
+ const hint = document.querySelector(".title-mode-hint");
+ if (hint) {
+ hint.innerHTML = currentPlainEnglishEnriching
+ ? 'Preparing plain-English summaries — toggle available shortly… '
+ : 'Showing ' + (currentPlainEnglishView ? "plain-English summaries" : "raw errors") + ' ';
+ }
}
// Flip the whole canvas between raw error and plain-English titles. Optimistic:
@@ -1854,6 +1931,7 @@ export function Page({
document.addEventListener("change", (e) => {
const box = e.target.closest("#title-mode-switch");
if (!box) return;
+ if (currentPlainEnglishEnriching) return;
currentPlainEnglishView = box.checked;
syncTitleSwitch();
renderCategories(currentCategories);
@@ -1878,6 +1956,10 @@ export function Page({
// empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
+ // "stale" means the user changed the org/project while the lookup was
+ // in flight — they've already moved on, so don't scan the old scope
+ // and don't nag them about a slug they abandoned.
+ if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -2167,7 +2249,7 @@ export function Page({
const keys = startableSelectedKeys();
if (keys.length === 0) return;
keys.forEach((key) => {
- workByIssue[key] = { phase: "queued" };
+ workByIssue[key] = { phase: "queued", copilotFix: false };
selectedKeys.delete(key);
});
applySelections();
@@ -2184,12 +2266,12 @@ export function Page({
const models = {};
keys.forEach((key) => {
if (modelByKey[key]) models[key] = modelByKey[key];
- workByIssue[key] = { phase: "queued" };
+ workByIssue[key] = { phase: "queued", copilotFix: true };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
- showToast("🔧 Creating/reusing issues and starting Copilot for " + keys.length + " issues…");
+ showToast("🔧 Starting a background Copilot session for " + keys.length + " issue" + (keys.length === 1 ? "" : "s") + " — you can keep triaging while it runs…");
postWorkSelected(keys, { keys, modelByKey: models, assignCopilot: true });
});
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs
index a6553594..3abdf09a 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs
@@ -205,6 +205,35 @@ function safeIssueKey(value) {
const servers = new Map()
+// Live model catalog fetched from the host (session.rpc.model.list()), cached
+// here so a newly opened panel gets it immediately instead of waiting on
+// another RPC round trip. Stays null until the first successful fetch; every
+// server keeps its state.mjs static fallback list until then.
+let liveModels = null
+
+// Fetches the host's current model catalog and pushes it into every open
+// panel's state (plus caches it for panels opened afterward), so the picker
+// tracks whatever models the host makes available without editing this
+// extension's static list every time a model ships or is retired. Best-effort:
+// on any failure (older host, RPC error) every panel just keeps using the
+// static fallback list already seeded in state.mjs.
+async function refreshAvailableModels() {
+ try {
+ const result = await session.rpc.model.list()
+ const models = (result?.list || [])
+ .map((m) => ({ id: String(m?.id ?? ''), label: String(m?.name ?? m?.id ?? '') }))
+ .filter((m) => m.id)
+ if (models.length === 0) return
+ liveModels = models
+ for (const entry of servers.values()) {
+ entry.state.setAvailableModels(models)
+ entry.notifyClients()
+ }
+ } catch (err) {
+ console.error('[sentry-triage] could not fetch live model list, using static fallback:', err?.message || err)
+ }
+}
+
// Maps an opaque per-work token -> { entry, key } for a "Work on selected"
// hand-off. The spawned fix session echoes this token back via `submit_work_pr`
// so the PR update lands on the EXACT canvas instance and issue that started the
@@ -739,6 +768,20 @@ OR
{"status":"error","error":"plain explanation","issue":{"number":123,"url":"https://..."},"session":{"id":null,"name":""}}`
}
+// Reset the plain-English enriching flag when an in-flight enrichment is
+// invalidated (scan generation bumped) by a path that will NOT start a
+// replacement scan to clear it. triageSentry's isCurrent() guard deliberately
+// stops a STALE scan from clearing a NEWER scan's flag; the side effect is that
+// an invalidation with no successor (a refresh that finds Sentry unreachable, or
+// a repo switch via onInvalidateEnrichment) would otherwise strand the flag
+// `true` forever and leave the plain-English toggle disabled. Clearing it here
+// is safe: a reachable scan's triageSentry re-sets it true before it drops the
+// scanning overlay, so the toggle is never interactive with a false flag.
+function resetEnrichingFlag(entry) {
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
+}
+
async function triageSentry(entry) {
if (entry.closed) return
const org = entry.state.getOrg()
@@ -788,8 +831,16 @@ async function triageSentry(entry) {
// 240s; blocking the whole board on it would leave the user staring at a
// spinner. The card renderer already falls back to the raw title when
// `plainEnglish` is absent, so an early render is fully usable.
+ // Set the enriching flag BEFORE publishing the categories snapshot so that
+ // snapshot already reports enrichment as in-progress. Publishing categories
+ // first would emit a snapshot saying enrichment is idle (re-enabling the
+ // toggle), and a user action slipping in before the separate
+ // plainEnglishEnriching:true event preserves a narrow version of the race
+ // this flag exists to remove.
+ entry.state.setPlainEnglishEnriching(true)
entry.state.setCategories(categories)
entry.notifyClients()
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(true)
// End the scanning overlay NOW that the board is published — enrichment is
// optional polish (plain-English titles + tracked badges) that streams in via
// a later publish. Leaving the overlay up for the whole ≤240s enrich turn
@@ -805,7 +856,14 @@ async function triageSentry(entry) {
// so a stale turn (a newer scan started meanwhile) can't apply its inbox data
// over the newer scan's state.
await enrichPlainEnglish(entry, categories, isCurrent)
+ // A stale turn (a newer scan started during our ≤240s enrich) must NOT clear
+ // or broadcast the shared enriching flag: doing so would re-enable the toggle
+ // while the newer scan is still enriching, recreating the exact race this
+ // flag prevents. Bail before touching it — the current scan owns the flag and
+ // clears it when IT finishes (and the guarded `finally` is the fallback).
if (!isCurrent()) return
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
entry.state.setCategories(categories)
entry.notifyClients()
console.error('[sentry-triage] Scan complete, categories:', categories.length, error ? `error: ${error}` : '')
@@ -821,6 +879,10 @@ async function triageSentry(entry) {
entry.notifyClients()
}
} finally {
+ if (isCurrent()) {
+ entry.state.setPlainEnglishEnriching(false)
+ if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
+ }
if (isCurrent() && entry.notifyScanning) entry.notifyScanning(false)
}
}
@@ -915,7 +977,7 @@ After the tool call(s), reply to the user with a confirmation sentence, then a b
Triaged ${total} Sentry ${noun}.
-**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default — flip the **Plain-English titles** switch above the issue list for readable summaries. Open the ⚙️ Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
+**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default — flip the **Plain-English messages** switch above the issue list for readable summaries. Open the ⚙️ Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
Keep it friendly and brief; do not include the summaries or JSON.
@@ -1134,19 +1196,62 @@ async function discoverProjects(entry, org, { force = false } = {}) {
const conn = entry.state.getConnections()
if (!conn || !conn.sentry || !conn.sentry.reachable) return
if (!force && entry._projectsFetchedFor === slug) return
+ // Each page now releases the shared SDK queue, so discoveries for different
+ // orgs (A then B) can interleave. Stamp this discovery with a generation
+ // token; a newer discoverProjects call bumps it, and every publish below
+ // (streamed partial, final store, catch path) bails once superseded — so a
+ // slow org-A traversal can't write A's slugs into the snapshot after org B
+ // has taken over (which would leave org === B but projectsOrg === A).
+ const myGen = (entry._projectsGen = (entry._projectsGen || 0) + 1)
+ const isCurrentGen = () => entry._projectsGen === myGen && !entry.closed
try {
+ // Seed the keep-longest guard from any list a PRIOR run already published for
+ // this org. The streamed partials below feed newly connected clients (they
+ // read this server snapshot), so an early page of a retry must not overwrite
+ // a longer prior result — the final-store guard alone can't protect a client
+ // that connects mid-stream.
+ const priorKnown = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
+ let storedLen = Array.isArray(priorKnown) ? priorKnown.length : 0
// Stream results: push each page to the panel as it arrives so the first
// ~100 projects light up the autocomplete in ~1s while the rest fill in.
- const projects = await listProjects(slug, (partial) => {
- entry.state.setProjects(partial, slug)
+ const { projects, complete } = await listProjects(slug, (partial) => {
+ // A newer discovery has superseded this one — drop the partial rather than
+ // publishing a stale org's slugs into the shared snapshot.
+ if (!isCurrentGen()) return
+ // Only store a partial that GROWS the snapshot beyond what's already
+ // published for this org. Within one traversal partials grow monotonically;
+ // this guard is what stops a retry's small first page from shrinking a
+ // longer prior list for clients that connect between pages.
+ if (!Array.isArray(partial) || partial.length <= storedLen) return
+ storedLen = partial.length
+ entry.state.setProjects(partial, slug, false)
entry.notifyClients()
})
- entry._projectsFetchedFor = slug
- entry.state.setProjects(projects, slug)
+ // Superseded while we were traversing: skip the final store (and the cache
+ // marker) so the winning discovery owns the snapshot.
+ if (!isCurrentGen()) return
+ // A COMPLETE traversal is authoritative: publish it as-is even if it's
+ // shorter than a prior run, so projects deleted upstream actually leave the
+ // dropdown. Only an INCOMPLETE traversal (a mid-mega-org page stall/failure)
+ // must be prevented from shrinking a longer list we already have — the
+ // "sometimes the list is much shorter" symptom. The finality flag rides
+ // along in the snapshot so the client applies the same rule.
+ const known = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
+ const best = complete
+ ? projects
+ : (Array.isArray(known) && known.length > projects.length ? known : projects)
+ // Only treat the list as definitively cached when the traversal actually
+ // ran to completion. A truncated run must stay retryable, otherwise the
+ // first unlucky attempt pins a partial list for the life of the panel.
+ if (complete) entry._projectsFetchedFor = slug
+ entry.state.setProjects(best, slug, complete)
entry.notifyClients()
- console.error('[sentry-triage] discovered projects for', slug, '->', projects.length)
+ console.error('[sentry-triage] discovered projects for', slug, '->', best.length)
} catch (err) {
console.error('[sentry-triage] project discovery failed:', err instanceof Error ? err.message : err)
+ // Superseded by a newer discovery — its own publish owns the snapshot; don't
+ // clobber it with this stale org's partial/empty list.
+ if (!isCurrentGen()) return
// The client already received a 200 from POST /api/list-projects (that
// request just kicks off this async fetch), so its own catch never runs and
// the autocomplete's loading indicator would pulse forever. Push a project
@@ -1155,7 +1260,7 @@ async function discoverProjects(entry, org, { force = false } = {}) {
// set `_projectsFetchedFor` here, so a later navigation or force refresh can
// retry a transient failure instead of caching the empty result.
const partial = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
- entry.state.setProjects(partial, slug)
+ entry.state.setProjects(partial, slug, false)
entry.notifyClients()
}
}
@@ -1236,6 +1341,11 @@ async function refreshAll(entry) {
// generation up front fails their isCurrent() guard immediately. triageSentry()
// bumps it again when it runs, which is fine.
entry.scanGen = (entry.scanGen || 0) + 1
+ // The bump above invalidated any enrichment still in flight. Clear its flag now
+ // so a path that never runs a replacement triageSentry (the Sentry-unreachable
+ // return below) can't leave the plain-English toggle disabled forever. A
+ // reachable scan re-sets it true before dropping the overlay.
+ resetEnrichingFlag(entry)
// Claim an ordering token for THIS refresh. The refresh endpoint is fire-and-
// forget, so two rapid refresh/period/project actions can resolve out of order:
// a slower, older refresh could publish stale connection state, clear the newer
@@ -1460,7 +1570,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// error) before re-queuing, so stale issue/PR/session links don't survive the
// shallow-merge into the new run's status.
entry.state.startWorkAttempt(key)
- entry.notifyWork(key, { phase: 'queued' })
+ entry.notifyWork(key, { phase: 'queued', copilotFix: wantsCopilot })
}
// On a timeout we must `return` (can't pile a new turn onto the shared session
@@ -1500,7 +1610,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
const workToken = randomUUID()
workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: prExpectedRepo, authorizedHost: allowedHost })
- entry.notifyWork(key, { phase: 'working', error: '' })
+ entry.notifyWork(key, { phase: 'working', error: '', copilotFix: wantsCopilot })
try {
const response = await runSessionTurn(() => {
// This closure runs only when the shared-session chain drains to it,
@@ -1665,7 +1775,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// retry can't race the live session into a duplicate. Otherwise there's no
// session to wait on — release the token and surface a retryable error.
if (handedOff) {
- entry.notifyWork(key, { phase: 'working' })
+ entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
continue
}
@@ -1776,7 +1886,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// session and then dies without spawning, no callback ever arrives; the
// bounded scheduleWorkReconcile below is the safety net that eventually
// releases the token and returns the card to a retryable error.
- entry.notifyWork(key, { phase: 'working' })
+ entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
strandRemaining(i + 1)
return
@@ -2040,10 +2150,19 @@ const session = await joinSession({
// from the previous repo fails its isCurrent() guard and applies
// none of its (now stale) tracking / related-issue data.
entry.scanGen = (entry.scanGen || 0) + 1
+ // No replacement scan runs on a repo switch, so clear the enriching
+ // flag here too — otherwise the invalidated enrichment (barred from
+ // clearing it by its isCurrent() guard) would leave the toggle
+ // disabled forever.
+ resetEnrichingFlag(entry)
},
defaults: runtimeDefaults,
})
servers.set(ctx.instanceId, entry)
+ // Apply whatever live catalog we already fetched (best-effort; the
+ // static fallback in state.mjs stands until refreshAvailableModels
+ // resolves for the first time).
+ if (liveModels) entry.state.setAvailableModels(liveModels)
} else if (runtimeDefaults.repo) {
// Panel already existed (e.g. opened before the repo resolved) — top
// up its targets now and push the update to any connected clients.
@@ -2094,3 +2213,8 @@ const session = await joinSession({
// Now that we're joined to the host, resolve the driving session's real repo so
// the PR/Issue targets default correctly (must run before any canvas opens).
await seedDefaultsFromSession()
+
+// Kick off the live model catalog fetch in the background; the static list in
+// state.mjs stands until it resolves, so panels are usable immediately either
+// way. Not awaited: model.list() is best-effort and shouldn't delay startup.
+refreshAvailableModels()
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json
index f98cb2f0..525046d6 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json
@@ -1,6 +1,6 @@
{
"name": "sentry-triage",
- "version": "1.1.0",
+ "version": "1.2.0",
"main": "extension.mjs",
"author": "Liz Tom",
"license": "MIT",
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs
index b294ed47..fa0fc142 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs
@@ -16,8 +16,7 @@ import {
orgList,
projectView,
issueList,
- projectListRaw,
- runSerial,
+ projectListPage,
SentryError,
} from './sentryClient.mjs'
@@ -221,52 +220,45 @@ export function categorize({ issues = [], regressed = new Set(), escalating = ne
}
// All project slugs in an org. Pages through the list (the API caps each page at
-// 100) so orgs with many projects are fully represented in the dropdown. The
-// loop is bounded and stops as soon as a page adds no new slugs, so it stays
-// safe even if the underlying cursor doesn't advance. Throws SentryError on an
-// auth/permission failure.
+// 100) so orgs with many projects are fully represented in the dropdown. Throws
+// SentryError on an auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
- // Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
- // the symbolic "next" cursor through global per-command state, so pages must not
- // interleave with each other or with any other SDK call (a concurrent issue
- // scan, another instance's discovery, a second traversal of this same org).
- // runSerial holds the module-wide queue for the whole loop, which guarantees
- // that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
- // the queue we already hold.
- return runSerial(() => listProjectsPaged(org, onPage))
-}
-
-async function listProjectsPaged(org, onPage) {
+ // Each page is its own runSerial task (see projectListPage), so a mega-org's
+ // traversal no longer holds the shared SDK queue end-to-end. That is what lets
+ // an interactive project.view lookup interleave and answer in ~1s instead of
+ // waiting out the whole list — the "Checking Sentry…" hang.
const seen = new Set()
const out = []
const PAGE = 100
- const MAX_PAGES = 20
- // Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
- // take minutes to fully page through, leaving the autocomplete spinning. Stop
- // once we've spent this long and return what we have — the client filters the
- // collected slugs, and a few hundred is plenty to type against. With streaming
- // (onPage) the first page is usable almost immediately regardless.
- const BUDGET_MS = 8000
- const started = Date.now()
+ // Bound the traversal by PAGES, not wall-clock. A time budget made the result
+ // depend on how fast the network happened to be, so the same org yielded a
+ // different-length list run to run (and a slow run could cache a truncated list
+ // over a previously complete one). A page cap is deterministic: the same org
+ // always yields the same list.
+ const MAX_PAGES = 60
let cursor
+ let complete = false
for (let page = 0; page < MAX_PAGES; page++) {
- let raw
+ let result
try {
- raw = await projectListRaw(org, PAGE, cursor)
+ result = await projectListPage(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
- // not discard the projects we already collected. Surface the error only
- // when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
+ // not discard the projects we already collected. Observed against a
+ // mega-org: the first pages return in ~300ms each and then a later page
+ // stalls before failing, so this path is routine, not exceptional. Surface
+ // the error only when we have nothing at all (e.g. page 0 failed => likely
+ // auth/bad org).
if (out.length) break
throw err
}
let added = 0
- for (const slug of mapProjects(raw)) {
+ for (const slug of mapProjects(result.projects)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
@@ -275,11 +267,26 @@ async function listProjectsPaged(org, onPage) {
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
- if (raw.length < PAGE || added === 0) break
- if (Date.now() - started > BUDGET_MS) break
- cursor = 'next'
+ // Terminal condition: the SDK's own envelope says there are no further
+ // pages. A short page is NOT authoritative on its own — the SDK can return
+ // fewer than requested and still report hasMore:true — so `hasMore` is the
+ // only signal that marks the traversal complete and cacheable. (When the
+ // envelope omits hasMore, projectListPage defaults it to false, which
+ // conservatively ends the traversal rather than looping forever.)
+ if (!result.hasMore) { complete = true; break }
+ // The SDK says more pages exist but handed back no usable cursor to fetch
+ // them, or the cursor did not advance (it returned the same token again).
+ // Either way we can't safely continue, and the traversal is NOT complete —
+ // leave `complete` false so the caller keeps it retryable rather than
+ // pinning a truncated list. Detect this by CURSOR progress, not record
+ // content: a page can legitimately add zero new slugs (all duplicates or
+ // filtered records) while still advancing its cursor, and stopping on
+ // `added === 0` would pin that boundary so every retry halts there and later
+ // projects never reach the dropdown.
+ if (!result.nextCursor || result.nextCursor === cursor) break
+ cursor = result.nextCursor
}
- return out
+ return { projects: out, complete }
}
// Verify a specific project slug exists / is accessible. The org's project list
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs
index 7cba248f..85939b5c 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs
@@ -143,9 +143,11 @@ async function getSdk() {
//
// `runSerial(task)` runs `task` only once all previously enqueued work has
// settled, so at most one SDK operation is ever in flight process-wide. It is the
-// single choke point; the public functions below are thin queued wrappers, and
-// multi-call traversals (see projectListRaw) run as ONE task so their paging
-// can't interleave with anything.
+// single choke point; the public functions below are thin queued wrappers. Each
+// task should be a SINGLE SDK call: a multi-call traversal that holds the queue
+// for its whole run starves every interactive lookup behind it (see
+// projectListPage, which pages via an explicit cursor so each page queues
+// independently).
let sdkQueue = Promise.resolve()
export function runSerial(task) {
@@ -279,17 +281,39 @@ export async function orgList(limit = 100) {
return runSerial(async () => asArray(await (await getSdk()).org.list({ limit })))
}
-// Single-page project fetch within an org. Deliberately NOT wrapped in
-// runSerial on its own: the CLI's positional org/project value treats a BARE
-// slug as a *project*, so listing every project in an org requires the trailing
-// `/` form — we normalize to exactly one trailing slash here. Raw project
-// objects (each has a `slug`). `cursor` navigates pages ("next"/"prev"/raw
-// cursor). Callers that page through the full list must instead run
-// projectListRaw inside a single runSerial task so the whole traversal is atomic
-// (see listProjects in sentry.mjs).
-export async function projectListRaw(org, limit = 100, cursor) {
+// Single page of an org's project list, fetched as its OWN queued task and
+// returning an explicit `nextCursor` so the caller can resume without holding
+// the queue between pages.
+//
+// This is what keeps a mega-org's multi-page traversal from starving the rest of
+// the UI. The symbolic "next" cursor resolves through the SDK's module-global
+// per-command state, so a traversal that used it had to hold the whole sdkQueue
+// for every page (otherwise an interleaved call would clobber that state) —
+// which meant a slow `github`-sized list blocked the O(1) project.view lookup
+// behind it for minutes and left "Checking Sentry…" spinning. The envelope's
+// `nextCursor` is a self-contained opaque token, so each page can be an
+// independent runSerial task and interactive lookups can slot in between them.
+//
+// The CLI's positional org/project value treats a BARE slug as a *project*, so
+// listing every project in an org requires the trailing `/` form — we
+// normalize to exactly one trailing slash here.
+//
+// Returns { projects, nextCursor, hasMore }. `hasMore` is the SDK envelope's own
+// "there are further pages" signal (defaults to false when the envelope omits
+// it); `nextCursor` is '' unless the SDK handed back a usable opaque token. The
+// two are reported separately so the caller can tell "genuinely done" (hasMore
+// false) apart from "more pages exist but we have no cursor to fetch them" — the
+// latter is an incomplete traversal, not a terminal one, and must stay retryable.
+export async function projectListPage(org, limit = 100, cursor) {
const orgProject = `${String(org || '').replace(/\/+$/, '')}/`
- return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
+ const res = await runSerial(async () => (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
+ const projects = asArray(res)
+ // Only trust an explicit opaque cursor. A truthy `hasMore` without a usable
+ // `nextCursor` would otherwise tempt us back onto the symbolic "next" token
+ // and reintroduce the global-state coupling this function exists to avoid.
+ const next = res && typeof res === 'object' && typeof res.nextCursor === 'string' ? res.nextCursor.trim() : ''
+ const hasMore = res && typeof res === 'object' ? res.hasMore === true : false
+ return { projects, nextCursor: hasMore ? next : '', hasMore }
}
// Verify a specific project exists / is accessible via the SDK's O(1)
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs
index d4aef00e..35461b81 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs
@@ -108,11 +108,13 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
+ projectsComplete: state.getProjectsComplete(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
+ plainEnglishEnriching: state.getPlainEnglishEnriching(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
@@ -153,6 +155,15 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
}
}
+ // Broadcast plain-English enrichment start/stop so the client can disable the
+ // toggle (and show a "preparing…" hint) until summaries are ready.
+ function notifyPlainEnglishEnriching(isEnriching) {
+ const data = JSON.stringify({ plainEnglishEnriching: Boolean(isEnriching) })
+ for (const res of sseClients) {
+ res.write(`data: ${data}\n\n`)
+ }
+ }
+
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
@@ -546,6 +557,7 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
notifyWork,
notifyFlash,
notifyScanning,
+ notifyPlainEnglishEnriching,
close,
})
})
diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/state.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/state.mjs
index b1116b1b..7eb6fcfa 100644
--- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/state.mjs
+++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/state.mjs
@@ -52,6 +52,12 @@ export function createState() {
// SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal).
let projectsOrg = ''
+ // Finality of the current `projects` list: true only when the traversal that
+ // produced it ran to completion (SDK reported no further pages). A COMPLETE
+ // list is authoritative and may legitimately be shorter than a prior one
+ // (projects deleted upstream); an INCOMPLETE/streamed one must not shrink a
+ // longer list. Consumed by the client's keep-longest guard via the snapshot.
+ let projectsComplete = false
// Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below).
@@ -68,6 +74,7 @@ export function createState() {
// Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly
// what Sentry reported first; the user can flip to plain English from the header.
let plainEnglishView = false
+ let plainEnglishEnriching = false
let selectedTracker = 'github'
const workByIssueKey = {}
// The issue trackers the "Work on selected" hand-off can file into. The agent
@@ -79,18 +86,27 @@ export function createState() {
{ id: 'linear', label: 'Linear', connected: true },
{ id: 'atlassian', label: 'Jira (Atlassian)', connected: true },
]
- // Models the spawned remediation session can run under. The empty id means
- // "let the session pick its default model"; every other id must match a value
- // the host create_session tool accepts, or the hand-off would fail.
- const availableModels = [
- { id: '', label: 'Auto (session default)' },
+ // Models the spawned remediation session can run under. The empty id always
+ // means "let the session pick its default model". The rest of the list is
+ // seeded with a static fallback (used until the live catalog loads, or if
+ // that fetch ever fails) and is normally replaced at startup by
+ // setAvailableModels() with the host's live model list (see extension.mjs,
+ // which calls session.rpc.model.list()) so this never needs to be hand-edited
+ // again when new models ship.
+ const AUTO_MODEL = { id: '', label: 'Auto (session default)' }
+ const FALLBACK_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
+ { id: 'claude-opus-5', label: 'Claude Opus 5' },
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
+ { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol' },
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' },
+ { id: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash' },
+ { id: 'grok-4.5', label: 'Grok 4.5' },
]
- const MODEL_IDS = new Set(availableModels.map((model) => model.id))
+ let availableModels = [AUTO_MODEL, ...FALLBACK_MODELS]
+ let MODEL_IDS = new Set(availableModels.map((model) => model.id))
const prTargets = {
mode: 'local',
model: '',
@@ -245,6 +261,10 @@ export function createState() {
return projectsOrg
},
+ getProjectsComplete() {
+ return projectsComplete
+ },
+
getConnections() {
return connections
},
@@ -261,6 +281,26 @@ export function createState() {
return availableModels
},
+ // Replaces the model list with the host's live catalog (see
+ // session.rpc.model.list() in extension.mjs). Keeps the current selection
+ // if it's still valid in the new list; otherwise falls back to Auto so a
+ // model retired upstream can't leave prTargets pointing at a dead id.
+ setAvailableModels(models) {
+ const seen = new Set([''])
+ const deduped = []
+ for (const m of Array.isArray(models) ? models : []) {
+ const id = typeof m?.id === 'string' ? m.id.trim() : ''
+ const label = typeof m?.label === 'string' && m.label.trim() ? m.label.trim() : id
+ if (!id || seen.has(id)) continue
+ seen.add(id)
+ deduped.push({ id, label })
+ }
+ if (deduped.length === 0) return
+ availableModels = [AUTO_MODEL, ...deduped]
+ MODEL_IDS = new Set(availableModels.map((model) => model.id))
+ if (prTargets.model && !MODEL_IDS.has(prTargets.model)) prTargets.model = ''
+ },
+
getIssueTrackers() {
return issueTrackers
},
@@ -289,7 +329,7 @@ export function createState() {
project = typeof slug === 'string' ? slug.trim() : ''
},
- setProjects(list, org) {
+ setProjects(list, org, complete = false) {
const seen = new Set()
const out = []
for (const item of Array.isArray(list) ? list : []) {
@@ -300,6 +340,7 @@ export function createState() {
}
projects = out
if (typeof org === 'string') projectsOrg = org.trim().toLowerCase()
+ projectsComplete = complete === true
return projects
},
@@ -346,7 +387,17 @@ export function createState() {
return plainEnglishView
},
+ getPlainEnglishEnriching() {
+ return plainEnglishEnriching
+ },
+
+ setPlainEnglishEnriching(on) {
+ plainEnglishEnriching = Boolean(on)
+ return plainEnglishEnriching
+ },
+
togglePlainEnglishView() {
+ if (plainEnglishEnriching) return plainEnglishView
plainEnglishView = !plainEnglishView
return plainEnglishView
},
diff --git a/plugins/sentry-triage/plugin.json b/plugins/sentry-triage/plugin.json
index 1e2791e3..eac63928 100644
--- a/plugins/sentry-triage/plugin.json
+++ b/plugins/sentry-triage/plugin.json
@@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "sentry-triage",
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
- "version": "1.1.0",
+ "version": "1.2.0",
"author": {
"name": "Liz Tom",
"url": "https://github.com/liztom"