mirror of
https://github.com/github/awesome-copilot.git
synced 2026-09-07 08:15:58 +00:00
chore: publish from main
This commit is contained in:
+3
-1
@@ -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 ✓'
|
||||
|
||||
+97
-15
@@ -48,6 +48,7 @@ export function Page({
|
||||
prTargets,
|
||||
prSettingsOpen,
|
||||
plainEnglishView = false,
|
||||
plainEnglishEnriching = false,
|
||||
projects = [],
|
||||
availableModels = [],
|
||||
issueTrackers,
|
||||
@@ -313,14 +314,14 @@ export function Page({
|
||||
</div>
|
||||
|
||||
<div id="title-mode-bar" class="title-mode-bar" style="${hasOrg && totalIssues > 0 ? '' : 'display:none;'}">
|
||||
<label class="switch-label" for="title-mode-switch" title="Switch every card title between the raw Sentry error and a plain-English summary">
|
||||
<label class="switch-label" for="title-mode-switch" title="Switch every card message between the raw Sentry error and a plain-English summary">
|
||||
<span class="switch">
|
||||
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''} />
|
||||
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''}${plainEnglishEnriching ? ' disabled' : ''} />
|
||||
<span class="switch-slider" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="switch-text">Plain-English titles</span>
|
||||
<span class="switch-text">Plain-English messages</span>
|
||||
</label>
|
||||
<span class="title-mode-hint">Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong></span>
|
||||
<span class="title-mode-hint">${plainEnglishEnriching ? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>' : `Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong>`}</span>
|
||||
</div>
|
||||
|
||||
<main id="categories">
|
||||
@@ -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
|
||||
// <select> 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
|
||||
? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>'
|
||||
: 'Showing <strong id="title-mode-state">' + (currentPlainEnglishView ? "plain-English summaries" : "raw errors") + '</strong>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sentry-triage",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"main": "extension.mjs",
|
||||
"author": "Liz Tom",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
-13
@@ -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
|
||||
// `<org>/` 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 `<org>/` 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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user