Files
awesome-copilot/extensions/sentry-triage/components/page.mjs
T
github-actions[bot] 42e27e0fdc chore: publish from main
2026-08-26 00:04:55 +00:00

2036 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { styles } from '../styles.mjs'
import { Category } from './category.mjs'
import { escapeHtml, jsonForScript } from '../escape.mjs'
// Subtitle + empty-state copy shared by the server render below. The inline
// client script keeps byte-identical wording (search "KEEP IN SYNC") so the
// first paint and every SSE re-render read the same. `total` is how many open
// issues the scan examined; `capped` means the scan hit its configured cap
// so we render "N+" to signal there may be more.
function scannedLabel(total, capped) {
return capped ? String(total) + '+' : String(total)
}
function subtitleText(shown, total, capped) {
if (total > shown) {
return 'Top ' + shown + ' of ' + scannedLabel(total, capped) + ' open issues, prioritized by impact'
}
return shown + ' open issue' + (shown === 1 ? '' : 's') + ' to triage'
}
function emptyText(total, capped, project, older = 0, olderCapped = false) {
const where = project ? ' for ' + project : ''
if (total > 0) {
return '✓ Scanned ' + scannedLabel(total, capped) + ' open issue' + (total === 1 ? '' : 's') + where +
' — none are urgent enough to surface right now.'
}
if (older > 0) {
const one = older === 1 && !olderCapped
return '✓ No open issues' + where + ' in this window — Sentry has ' + scannedLabel(older, olderCapped) +
' unresolved issue' + (one ? '' : 's') + ' in the last 90 days.'
}
return '✓ No open issues' + where + ' in this window.'
}
export function Page({
categories,
scanError = '',
scannedTotal = 0,
scannedCapped = false,
scannedOlder = 0,
scannedOlderCapped = false,
org,
orgOptions = [],
orgDefault = '',
savedDefaultOrg = '',
project,
period = '24h',
periods = [],
connections,
prTargets,
prSettingsOpen,
plainEnglishView = false,
projectOptions = [],
projects = [],
availableModels = [],
issueTrackers,
selectedTracker,
workByIssueKey,
csrfToken = '',
}) {
const conn = connections && typeof connections === 'object'
? connections
: { checked: false, sentry: { reachable: false, error: '' } }
const checked = Boolean(conn.checked)
// Optimistic before the first preflight so we never flash a gate spuriously.
const sentryReady = !checked || Boolean(conn.sentry && conn.sentry.reachable)
// The humanized preflight failure (unsupported host, dropped connection, sign-in
// required, …). Shown in the gate so users see the real reason instead of a
// generic "connect" message for problems reconnecting won't fix.
const gateError = (conn.sentry && conn.sentry.error) || ''
// Distinguish "not signed in" (needs `sentry auth login`) from a transient
// connectivity failure where a credential likely exists. probeSentry() reports
// configured:false only for auth failures, so a gated-but-configured state means
// Sentry was simply unreachable — the gate then shows connectivity guidance
// instead of wrongly telling an already-authenticated user to re-run login.
const signedOut = !(conn.sentry && conn.sentry.configured)
// Among configured-but-unreachable failures, only a transient network blip is
// worth promising recovery for. An unknown, settled failure (classifySentryError
// marks transient:false) gets neutral guidance instead of a false "it should
// recover on the next check" — the humanized error carries the real reason.
const transientConn = Boolean(conn.sentry && conn.sentry.transient)
const categoriesWithStatus = categories.map((category) => ({
...category,
issues: category.issues.map((issue) => ({
...issue,
workStatus: workByIssueKey[issue.key],
})),
}))
const totalIssues = categoriesWithStatus.reduce((sum, c) => sum + c.issues.length, 0)
const hasOrg = Boolean(org)
// Whether the currently-prefilled setup org is the one the user explicitly
// saved as their default. Drives the ⭐ control's active state on first paint.
const isSavedDefault = Boolean(savedDefaultOrg) && orgDefault === savedDefaultOrg
// Setup-form org control. find_organizations reliably enumerates the orgs this
// Sentry connection can access, so we prefill the slug input with the best match
// (orgDefault) and, when there's more than one org, add a native <select> that
// mirrors its choice into the input. Native selects repaint reliably in the app
// webview (a <datalist> does not), so this is the safe dropdown primitive.
const orgSlugs = Array.isArray(orgOptions) ? orgOptions.filter(Boolean) : []
const orgSelect = orgSlugs.length >= 2
? `<select id="org-select" class="org-input org-select" title="Sentry organizations you can access">` +
orgSlugs
.map((slug) => `<option value="${escapeHtml(slug)}"${slug === orgDefault ? ' selected' : ''}>${escapeHtml(slug)}</option>`)
.join('') +
`</select>`
: ''
// Project autocomplete source. The Sentry CLI SDK returns the org's full
// project list, so the project field is a combobox (a text input + a filtered
// suggestion menu) rather than a typed slug box — scalable to orgs with many
// projects. Empty value = all projects. Rendered client-side; the server just
// seeds the input value and an empty menu container.
const projectSlugs = Array.isArray(projects) ? projects.filter(Boolean) : []
// A text input + suggestion menu wrapper. Shared shape for the setup screen and
// the header so the autocomplete JS can attach to either by id.
const projectComboInput = (id, cls, extraAttrs = '') =>
`<span class="project-ac">` +
`<input id="${id}" class="${cls}" role="combobox" aria-autocomplete="list" aria-expanded="false" ` +
`aria-controls="${id}-listbox" ` +
`placeholder="all projects — type to search" autocomplete="off" autocapitalize="none" autocorrect="off" ` +
`spellcheck="false" value="${escapeHtml(project)}"${extraAttrs} />` +
`<div id="${id}-listbox" class="project-ac-menu" role="listbox" hidden></div></span>`
// Header org control. Mirrors the project switcher so the org can be changed
// without returning to the setup screen. Renders as a dropdown when the
// connection exposes 2+ orgs (matching the setup screen), else a text input.
const headerOrgSlugs = !org || orgSlugs.includes(org) ? orgSlugs : [org, ...orgSlugs]
const orgSwitcherControl = headerOrgSlugs.length >= 2
? `<select id="org-switcher" class="project-select org-switcher-select" title="Sentry organizations you can access">` +
headerOrgSlugs
.map((slug) => `<option value="${escapeHtml(slug)}"${slug === org ? ' selected' : ''}>${escapeHtml(slug)}</option>`)
.join('') +
`</select>`
: `<input id="org-switcher" class="project-select" placeholder="org slug" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false" value="${escapeHtml(org)}" />`
const orgSwitcher = hasOrg
? `<label class="project-switcher"><span class="project-switcher-label">Org</span>` + orgSwitcherControl + `</label>`
: ''
// Header project switcher: an autocomplete combobox + an explicit Fetch button.
// Picking a suggestion re-scans that project immediately; a typed value applies
// on Enter or Fetch (empty = all projects).
const projectSwitcher = hasOrg
? `<label class="project-switcher"><span class="project-switcher-label">Project</span>` +
projectComboInput('project-switcher', 'project-select') +
`<button id="project-fetch" class="project-fetch-btn" type="button" title="Fetch tickets for this project">Fetch</button></label>`
: ''
// Time-window switcher. Defaults to the last 24h; changing it re-scans over the
// wider window so the user can look further back from the issues list.
const periodList = Array.isArray(periods) ? periods : []
const periodSwitcher = hasOrg
? `<label class="period-switcher"><span class="period-switcher-label">Time range</span>` +
`<select id="period-select" class="period-select" title="How far back to search Sentry">` +
periodList
.map((p) => `<option value="${escapeHtml(p.value)}"${p.value === period ? ' selected' : ''}>${escapeHtml(p.label)}</option>`)
.join('') +
`</select></label>`
: ''
// Registered app projects for the Local hand-off target dropdown. Loaded
// eagerly on open via the agent, so this may be empty on the very first render.
const projectList = Array.isArray(projectOptions) ? projectOptions : []
const localProjectId = prTargets && prTargets.local ? (prTargets.local.projectId || '') : ''
const projectOptionsHtml = projectList
.map((p) => {
const label = p.repo ? `${p.name} (${p.repo})` : p.name
return `<option value="${escapeHtml(p.id)}"${p.id === localProjectId ? ' selected' : ''}>${escapeHtml(label)}</option>`
})
.join('')
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>${styles()}</style>
</head>
<body class="${sentryReady ? '' : 'sentry-gated'}">
<header class="page-header">
<h1><svg class="sentry-logo" viewBox="11.05 11 49.95 44.04" role="img" aria-label="Sentry" focusable="false"><path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z" transform="translate(11 11)" fill="currentColor"/></svg><span>Sentry Triage</span></h1>
${hasOrg ? `<div class="header-controls"><span class="scope-switchers">${orgSwitcher}${projectSwitcher}</span><span class="control-divider" aria-hidden="true"></span>${periodSwitcher}<span class="control-divider" aria-hidden="true"></span><button id="refresh" class="refresh-btn" title="Re-scan errors"><span class="refresh-icon">↻</span> Re-scan</button></div>` : ''}
</header>
<section class="sentry-gate" role="alert">
<div class="gate-card">
<div class="gate-icon">⚠️</div>
<h2 id="gate-title">${signedOut ? 'Connect Sentry to start triaging' : 'Cant reach Sentry right now'}</h2>
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut ? '' : ' style="display:none;"'}>
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but it isn't signed in yet.</p>
<p class="gate-steps">Run <code>sentry auth login</code> in your terminal to sign in, then re-open this canvas.</p>
</div>
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn ? ' style="display:none;"' : ''}>
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry — usually a temporary network blip.</p>
<p class="gate-steps">It should recover on the next check. If it persists, check your network or VPN, then re-open this canvas.</p>
</div>
<div class="gate-body gate-body-unknown" id="gate-body-unknown"${signedOut || transientConn ? ' style="display:none;"' : ''}>
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry.</p>
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
</div>
<p class="gate-error" id="gate-error" ${sentryReady || !gateError ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
</div>
</section>
${!hasOrg ? `
<div id="setup-instructions" class="setup-instructions">
<h2>Triage your live Sentry errors</h2>
<p>Surfaces the issues that need attention <em>right now</em>. Set your org, project, and repo, then scan.</p>
<ol>
<li><strong>Org &amp; project</strong> — scope the scan.</li>
<li><strong>Repo</strong> — lets us flag issues already being worked on (👀 Tracked).</li>
<li><strong>Scan</strong> — then open a tracking issue, or start a Copilot fix session with a draft PR.</li>
</ol>
</div>
<div class="org-picker">
<form id="org-form" class="org-form">
<label class="org-field">
<span class="org-field-label">Organization</span>
${orgSelect
? orgSelect
: `<input type="text" id="org-input" class="org-input" placeholder="org slug (e.g. my-org)" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false" value="${escapeHtml(orgDefault)}"${orgDefault ? '' : ' autofocus'} />`}
</label>
<label class="org-field">
<span class="org-field-label">Project <span class="org-field-optional">(optional)</span></span>
${projectComboInput('project-input', 'org-input', orgDefault ? ' autofocus' : '')}
</label>
</form>
<div class="org-default" id="org-default-row">
<button id="set-default-org" class="set-default-btn" type="button" aria-pressed="${isSavedDefault ? 'true' : 'false'}" title="Remember this org and prefill it the next time you open the canvas">
<span class="set-default-star" aria-hidden="true">${isSavedDefault ? '★' : '☆'}</span>
<span class="set-default-text">${isSavedDefault ? 'Saved as your default org' : 'Set as default org'}</span>
</button>
<span class="set-default-hint">Saved on this machine to <code>~/.copilot/sentry-triage/preferences.json</code></span>
</div>
</div>
` : `
<p id="triage-desc" class="page-description">On-call error triage for <strong>${escapeHtml(org)}</strong>${project ? ` / <strong>${escapeHtml(project)}</strong>` : ''} — issues grouped by why they need your attention right now.</p>
<p class="page-subtitle">${escapeHtml(subtitleText(totalIssues, scannedTotal, scannedCapped))}${project ? '' : ' · all projects'}</p>
`}
<div id="work-toolbar" class="work-toolbar" style="${hasOrg && totalIssues > 0 ? '' : 'display:none;'}">
<div class="work-toolbar-row">
<div class="work-actions">
<button id="work-selected" class="work-selected-btn" disabled title="File or link a GitHub tracking issue only — no code, branch, or session.">📝 Create issue (0)</button>
<span class="action-or">or</span>
<button id="fix-with-copilot" class="fix-copilot-btn" disabled title="One-click: create or reuse the tracking issue, then start a Copilot fix session — respecting your Settings and model.">🔧 Fix with Copilot (0)</button>
<label class="toolbar-model" title="Model used by “Fix with Copilot”. Each selected card can override it.">
<span class="toolbar-model-label">Model</span>
<select id="toolbar-model" class="settings-select" disabled></select>
</label>
</div>
<button id="toggle-pr-settings" class="settings-toggle" title="Configure open issue and draft PR targets">⚙️ Settings</button>
</div>
<div class="work-summary-callout">
<span class="work-summary-title">Summary of the actions above</span>
<span id="work-target-summary" class="work-target-summary"></span>
</div>
</div>
<div id="work-settings" class="work-settings" style="${!hasOrg ? '' : (prSettingsOpen ? '' : 'display:none;')}">
<section class="settings-section">
<h3>Repo</h3>
<label class="settings-label">
Repo (owner/repo)
<input id="cloud-repo" class="settings-input" type="text" placeholder="owner/repo" autocapitalize="none" autocorrect="off" spellcheck="false" />
</label>
<p class="settings-hint">Where we check whether an issue already has a tracking issue or draft PR (shown as 👀 Tracked), and the default repo for cloud draft PRs. Recommended whenever you set a project.</p>
</section>
<section class="settings-section">
<h3>Open issue</h3>
<label class="settings-label">
Tracker
<select id="tracker-select" class="settings-select"></select>
</label>
</section>
<section class="settings-section">
<h3>Draft PR</h3>
<label class="settings-label">
Mode
<select id="pr-mode" class="settings-select">
<option value="local">Local — run on this machine</option>
<option value="cloud">Cloud — GitHub-hosted agent</option>
</select>
</label>
<p id="mode-hint" class="settings-hint"></p>
<div id="local-group" class="settings-subgroup${prTargets && prTargets.mode === 'cloud' ? ' dimmed' : ''}">
<span class="settings-group-title">Local target</span>
<label class="settings-label">
Project
<select id="local-project" class="settings-input">
<option value="">Current project (default)</option>
${projectOptionsHtml}
</select>
</label>
<div class="settings-row">
<label class="settings-label">
Local path
<input id="local-path" class="settings-input" type="text" />
</label>
<label class="settings-label">
Local base branch
<input id="local-branch" class="settings-input" type="text" />
</label>
</div>
</div>
<div id="cloud-group" class="settings-subgroup${prTargets && prTargets.mode === 'cloud' ? '' : ' dimmed'}">
<span class="settings-group-title">Cloud target</span>
<p class="settings-hint">Runs in the <strong>Repo</strong> above.</p>
<label class="settings-label">
Cloud base branch
<input id="cloud-branch" class="settings-input" type="text" />
</label>
</div>
<button id="save-pr-config" class="save-settings-btn">Save draft PR config</button>
</section>
</div>
<div id="setup-scan" class="setup-scan" style="${hasOrg ? 'display:none;' : ''}">
<button id="scan-btn" class="org-submit" type="button"${orgDefault ? '' : ' disabled'}>Scan</button>
</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">
<span class="switch">
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''} />
<span class="switch-slider" aria-hidden="true"></span>
</span>
<span class="switch-text">Plain-English titles</span>
</label>
<span class="title-mode-hint">Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong></span>
</div>
<main id="categories">
${categoriesWithStatus.map((cat) => Category({ ...cat, plainEnglishView, availableModels })).join('')}
</main>
<div id="empty-state" class="empty-state" style="display: none;">
<span id="empty-msg">${escapeHtml(emptyText(scannedTotal, scannedCapped, project, scannedOlder, scannedOlderCapped))}</span>
<p id="empty-tips" class="empty-tips">Nothing needs urgent attention in this window. Try widening the time range or switching projects.</p>
<button id="rescan" class="link-btn">Refresh to re-scan</button>
</div>
<div id="toast" class="toast" role="status" aria-live="polite" style="display: none;"></div>
<div id="scan-overlay" class="scan-overlay" style="display: none;" aria-live="polite" aria-busy="true">
<div class="scan-overlay-box">
<div class="scan-spinner"></div>
<div id="scan-overlay-text" class="scan-overlay-text">Scanning…</div>
</div>
</div>
<script>
// Per-instance capability token (embedded server-side, never broadcast over
// SSE). Attach it to every same-origin /api/ mutation so the server can tell
// a real canvas request from a cross-origin/DNS-rebinding forgery. Wrapping
// fetch once covers all mutation call sites — current and future — in one
// place instead of threading a header through each one.
const CSRF_TOKEN = ${jsonForScript(csrfToken || '')};
(function installCsrfFetch() {
const original = window.fetch.bind(window);
window.fetch = function (input, init) {
const rawUrl = typeof input === "string" ? input : (input && input.url) || "";
let path = rawUrl;
try { path = new URL(rawUrl, window.location.origin).pathname; } catch (e) { /* keep raw */ }
const method = String(
(init && init.method) || (typeof input === "object" && input && input.method) || "GET"
).toUpperCase();
if (method === "POST" && path.indexOf("/api/") === 0) {
const opts = { ...(init || {}) };
const headers = new Headers(
(init && init.headers) || (typeof input === "object" && input && input.headers) || {}
);
headers.set("x-sentry-triage-csrf", CSRF_TOKEN);
opts.headers = headers;
return original(input, opts);
}
return original(input, init);
};
})();
const HTML_ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function escapeHtml(value) {
if (value == null) return "";
return String(value).replace(/[&<>"']/g, (ch) => HTML_ENTITIES[ch]);
}
function safeHref(value) {
if (value == null) return "#";
const raw = String(value).trim();
try {
const url = new URL(raw);
if (url.protocol === "http:" || url.protocol === "https:") return escapeHtml(url.href);
} catch (e) { /* not absolute */ }
return "#";
}
// Declared up here (before the setup-form init below calls syncScanButtonState
// -> refreshDefaultBtn) so it isn't read in its temporal dead zone, which
// would throw and kill the whole inline script. Same reason as wasGatedPrev.
let currentSavedDefaultOrg = ${jsonForScript(savedDefaultOrg || '')};
// Per-org cache of discovered project lists, so re-selecting an org we've
// already loaded shows its projects instantly instead of waiting on the
// network again. Declared early because the setup-form init below can call
// requestProjectsForOrg while seeding the org select. Seeded at init.
const projectsByOrg = {};
function submitScan() {
const input = document.getElementById("org-input");
const projectInput = document.getElementById("project-input");
const repoInput = document.getElementById("cloud-repo");
const orgSelect = document.getElementById("org-select");
// Prefer the typed slug, but fall back to the multi-org dropdown's current
// value so scanning works even if its selection was never mirrored into
// the input (e.g. the user re-picked the already-shown first org, which
// fires no change event).
let org = input ? input.value.trim() : "";
if (!org && orgSelect) org = orgSelect.value.trim();
const project = projectInput ? projectInput.value.trim() : "";
const repo = repoInput ? repoInput.value.trim() : "";
if (!org) { if (input) input.focus(); return; }
if (input) { input.value = org; input.disabled = true; }
if (projectInput) projectInput.disabled = true;
const scanBtn = document.getElementById("scan-btn");
if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning…"; }
showScanOverlay(project ? "Scanning " + project + "…" : "Scanning all projects…");
fetch("/api/set-org", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org, project, repo })
}).then((res) => {
if (!res.ok) throw new Error("set-org " + res.status);
}).catch(() => {
// The loopback POST failed (server stopped, rejected, or a transport
// blip). Without this, the overlay would linger until its 250s safety
// timer while the form stayed disabled — a dead end with no retry path
// short of reopening the canvas. Restore the controls and tell the user
// so they can try again.
hideScanOverlay();
if (input) input.disabled = false;
if (projectInput) projectInput.disabled = false;
if (scanBtn) { scanBtn.disabled = false; scanBtn.textContent = "Scan"; }
syncScanButtonState();
window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again.");
});
}
const orgForm = document.getElementById("org-form");
if (orgForm) {
orgForm.addEventListener("submit", (e) => {
e.preventDefault();
submitScan();
});
}
const scanBtnEl = document.getElementById("scan-btn");
if (scanBtnEl) scanBtnEl.addEventListener("click", submitScan);
// Keep the Scan button disabled until an org slug is present so it never
// looks clickable while it can't actually scan.
function syncScanButtonState() {
const input = document.getElementById("org-input");
const orgSelect = document.getElementById("org-select");
const btn = document.getElementById("scan-btn");
if (btn) {
const hasOrg = (input && input.value.trim()) || (orgSelect && orgSelect.value.trim());
btn.disabled = !hasOrg;
}
refreshDefaultBtn();
}
const orgInputForSync = document.getElementById("org-input");
if (orgInputForSync) orgInputForSync.addEventListener("input", syncScanButtonState);
syncScanButtonState();
const orgSelectEl = document.getElementById("org-select");
if (orgSelectEl) {
const orgInputEl = document.getElementById("org-input");
const mirrorOrgSelect = () => {
if (orgInputEl) orgInputEl.value = orgSelectEl.value;
// Clear any project slug carried over from the previously-selected org —
// it won't exist under the new org's project list.
const projInput = document.getElementById("project-input");
if (projInput) projInput.value = "";
syncScanButtonState();
// Load the new org's projects into the autocomplete: instant from cache
// when we've seen it before, otherwise a "loading projects…" hint until
// the fetched list arrives over SSE.
requestProjectsForOrg(orgSelectEl.value);
};
orgSelectEl.addEventListener("change", mirrorOrgSelect);
// The <select> shows its first option as selected by default, but the
// input starts empty when there's no detected default — so Scan looks
// disabled and re-picking the already-shown org fires no change event.
// Seed the input from the select's current value on load so the visible
// selection is the effective one and Scan is enabled.
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) mirrorOrgSelect();
}
// The org slug currently chosen on the setup screen (typed input wins, else
// the multi-org dropdown's value). Lowercased to match how the server
// normalizes + stores the default.
function readSetupOrg() {
const input = document.getElementById("org-input");
const orgSelect = document.getElementById("org-select");
let org = input ? input.value.trim() : "";
if (!org && orgSelect) org = orgSelect.value.trim();
return org.toLowerCase();
}
// Repaint the ⭐ control to reflect whether the currently-chosen org is the
// saved default. Disabled when no org is chosen (nothing to save).
function refreshDefaultBtn() {
const btn = document.getElementById("set-default-org");
if (!btn) return;
const org = readSetupOrg();
const isDefault = Boolean(org) && org === currentSavedDefaultOrg;
btn.disabled = !org;
btn.setAttribute("aria-pressed", isDefault ? "true" : "false");
const star = btn.querySelector(".set-default-star");
const text = btn.querySelector(".set-default-text");
if (star) star.textContent = isDefault ? "★" : "☆";
if (text) text.textContent = isDefault ? "Saved as your default org" : "Set as default org";
}
// Explicit user action: save the chosen org as the default, or clear it when
// it's already the default (the ⭐ toggles). Persisted server-side to a
// local prefs file; we surface exactly where so the write isn't hidden.
const setDefaultBtn = document.getElementById("set-default-org");
if (setDefaultBtn) {
setDefaultBtn.addEventListener("click", () => {
const org = readSetupOrg();
if (!org) return;
const clearing = org === currentSavedDefaultOrg;
const next = clearing ? "" : org;
const prevSavedDefault = currentSavedDefaultOrg;
// Optimistic: update local state + button so it feels instant; the SSE
// snapshot will confirm.
currentSavedDefaultOrg = next;
refreshDefaultBtn();
fetch("/api/set-default-org", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org: next })
})
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data || data.ok === false) {
// The write failed server-side — undo the optimistic update so the
// button doesn't imply a preference that never persisted.
currentSavedDefaultOrg = prevSavedDefault;
refreshDefaultBtn();
showToast("⚠️ " + ((data && data.error) || "Couldn't save your default org"));
return;
}
const path = (data && data.path) || "~/.copilot/sentry-triage/preferences.json";
if (clearing) showToast("☆ Cleared your default org");
else showToast("★ Saved " + org + " as your default — " + path);
})
.catch(() => {
currentSavedDefaultOrg = prevSavedDefault;
refreshDefaultBtn();
showToast("⚠️ Couldn't save your default org — try again");
});
});
}
refreshDefaultBtn();
// Sentry may connect (or finish enumerating orgs) after the setup form has
// already rendered empty. When that snapshot arrives over SSE, drop the
// detected default into the still-empty slug input so the user doesn't have
// to type it. Never clobber a value the user has started editing.
function applyOrgDefault(def) {
if (!def) return;
const input = document.getElementById("org-input");
if (input && !input.value) {
input.value = def;
syncScanButtonState();
}
}
const source = new EventSource("/api/events");
const selectedKeys = new Set();
const workByIssue = {};
// Per-card model overrides (issueKey -> model id). Empty/absent means the
// card uses the toolbar's batch default model.
const modelByKey = {};
// Mirror the server's non-startable filter (onWorkSelected): a card whose
// work is already queued/working/handed-off is NOT restarted, and the
// server emits no status event for those keys. Optimistically stamping them
// "queued" here would make a live card look stuck with no correction coming
// back, so we exclude them from both the local mutation and the POST.
const ACTIVE_WORK_PHASES = new Set(["queued", "working", "handed-off"]);
const startableSelectedKeys = () =>
Array.from(selectedKeys).filter((key) => !ACTIVE_WORK_PHASES.has(workByIssue[key] && workByIssue[key].phase));
// Every per-card client-side set is keyed only by the Sentry SHORT ID, which
// can collide across orgs/projects. When the scope changes, a leftover entry
// would make the new scope's same-keyed card appear selected, and a stale
// selection could be submitted against the new incident. Wipe all of it
// before the new board renders. (workByIssue is also re-synced
// authoritatively from the server, but clearing it here avoids a flash.)
function resetPerCardState() {
selectedKeys.clear();
for (const k of Object.keys(workByIssue)) delete workByIssue[k];
for (const k of Object.keys(modelByKey)) delete modelByKey[k];
}
let currentCategories = ${jsonForScript(categories)};
let currentPrTargets = ${jsonForScript(prTargets)};
let currentIssueTrackers = ${jsonForScript(issueTrackers)};
let currentSelectedTracker = ${jsonForScript(selectedTracker)};
let currentPrSettingsOpen = ${jsonForScript(prSettingsOpen)};
let currentOrg = ${jsonForScript(org)};
let currentProject = ${jsonForScript(project)};
let currentPeriod = ${jsonForScript(period)};
let currentPeriods = ${jsonForScript(periodList)};
let currentProjectOptions = ${jsonForScript(projectList)};
// Sentry project slugs for the current org (SDK-discovered). Drives the
// project autocomplete; empty = fall back to a typed slug box.
let currentSentryProjects = ${jsonForScript(projectSlugs)};
let currentOrgOptions = ${jsonForScript(orgSlugs)};
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
let currentScanError = ${jsonForScript(scanError || '')};
let currentScannedTotal = ${jsonForScript(scannedTotal)};
let currentScannedCapped = ${jsonForScript(scannedCapped)};
let currentScannedOlder = ${jsonForScript(scannedOlder)};
let currentScannedOlderCapped = ${jsonForScript(scannedOlderCapped)};
// Declared up here (not next to applyConnectionState) because that function
// runs during init below — declaring it there with let would put it in the
// temporal dead zone and throw, killing the whole inline script.
let wasGatedPrev = null;
let currentConnections = ${jsonForScript(conn)};
Object.assign(workByIssue, ${jsonForScript(workByIssueKey)});
syncSettingsFromState();
updateBulkToolbar();
updateToolbarVisibility(Boolean(${jsonForScript(org)}) && hasRenderableIssues());
applyConnectionState();
// Seed the per-org project cache with the org already loaded at render time
// so switching away and back to it is instant.
if (currentOrg && Array.isArray(currentSentryProjects) && currentSentryProjects.length) {
projectsByOrg[currentOrg.trim().toLowerCase()] = currentSentryProjects;
}
if (currentOrg) renderProjectSwitcher();
else renderSetupProjectField();
// Single-org accounts render no #org-select, so the project-fetch trigger
// inside the org-select change handler never runs. When there's a prefilled
// org on the setup screen but no committed org yet and no project list
// loaded, kick off discovery directly so the autocomplete has projects
// without the user retyping the org. (Runs here, after currentOrg is
// initialized, to avoid a temporal-dead-zone reference.)
if (!currentOrg && !document.getElementById("org-select")) {
const soloOrgInput = document.getElementById("org-input");
const soloOrg = soloOrgInput ? soloOrgInput.value.trim() : "";
if (soloOrg && !(Array.isArray(currentSentryProjects) && currentSentryProjects.length)) {
requestProjectsForOrg(soloOrg);
}
}
if (currentOrg) renderPeriodSwitcher();
syncTitleSwitch();
function statusText(status) {
if (!status || typeof status !== "object") return "";
if (status.phase === "queued" || status.phase === "working") return "⏳ working…";
if (status.phase === "skipped") return "🔒 already being worked on";
if (status.phase === "tracked") return "👀 Tracked";
if (status.phase === "done") {
return "created ✓";
}
if (status.phase === "handed-off") {
const sessionPart = status.sessionName ? ("🧵 " + status.sessionName) : "🧵 session started";
return sessionPart + " ↗";
}
if (status.phase === "error") return "⚠️ " + (status.error || "work failed");
return "";
}
function statusClass(status) {
if (!status || typeof status !== "object") return "idle";
if (status.phase === "done") return "done";
if (status.phase === "handed-off") return "handed-off";
if (status.phase === "skipped") return "skipped";
if (status.phase === "tracked") return "tracked";
if (status.phase === "error") return "error";
if (status.phase === "queued" || status.phase === "working") return "working";
return "idle";
}
function statusLinksHtml(status) {
if (!status || typeof status !== "object") return "";
const stateSuffix = function (s) {
const v = typeof s === "string" ? s.trim().toLowerCase() : "";
return v ? (" (" + v + ")") : "";
};
const linkOrText = function (url, label) {
return url
? ('<a href="' + safeHref(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(label) + '</a>')
: ('<span>' + escapeHtml(label) + '</span>');
};
const links = [];
const phase = status.phase;
// Freshly created artifacts (done / handed-off): link the new issue and PR
// so the numbers are clickable, mirroring the existing tracked/skipped links.
if (phase === "done" || phase === "handed-off") {
if (status.issueUrl || status.issueNumber) {
const base = status.issueNumber ? ("issue #" + status.issueNumber) : "issue created";
links.push(linkOrText(status.issueUrl, base));
}
if ((phase === "done" || phase === "handed-off") && (status.prUrl || status.prNumber)) {
const base = status.prNumber ? ("PR #" + status.prNumber) : "PR opened";
const label = base + stateSuffix(status.prState);
links.push(linkOrText(status.prUrl, label));
}
}
if (status.existingIssueUrl) {
const base = status.existingIssueNumber ? ("issue #" + status.existingIssueNumber) : "existing issue";
const label = base + stateSuffix(status.existingIssueState);
links.push(linkOrText(status.existingIssueUrl, label));
}
if (status.existingPrUrl) {
const base = status.existingPrNumber ? ("PR #" + status.existingPrNumber) : "existing PR";
const label = base + stateSuffix(status.existingPrState);
links.push(linkOrText(status.existingPrUrl, label));
}
return links.join(" · ");
}
function workStatusHtml(status) {
const text = statusText(status);
if (!text) return "";
const links = statusLinksHtml(status);
return escapeHtml(text) + (links ? ('<span class="card-work-links">' + links + '</span>') : "");
}
function relatedHintHtml(issue) {
const list = issue && Array.isArray(issue.relatedIncidents) ? issue.relatedIncidents : [];
if (!list.length) return "";
const links = list.map(function (r) {
const label = r.number ? ("#" + r.number) : "issue";
const state = typeof r.state === "string" && r.state ? (" (" + r.state.toLowerCase() + ")") : "";
const title = typeof r.title === "string" && r.title ? r.title : ("issue " + label);
if (r.url) {
return '<a href="' + safeHref(r.url) + '" target="_blank" rel="noopener noreferrer" title="' + escapeHtml(title) + '">' + escapeHtml(label + state) + '</a>';
}
return '<span title="' + escapeHtml(title) + '">' + escapeHtml(label + state) + '</span>';
}).join(", ");
return '<span class="card-related" title="Same error text as an existing issue that isn\\'t a canvas tracking issue — verify before acting.">⚠️ Possibly related: ' + links + '</span>';
}
function applyWorkStates() {
document.querySelectorAll(".card-work-status").forEach((node) => {
const key = node.dataset.key;
const status = workByIssue[key];
const html = workStatusHtml(status);
node.classList.remove("working", "done", "handed-off", "skipped", "tracked", "error", "idle");
node.classList.add(statusClass(status));
if (html) {
node.style.display = "";
node.innerHTML = html;
} else {
node.style.display = "none";
node.textContent = "";
}
});
}
function applySelections() {
const checkboxes = Array.from(document.querySelectorAll(".issue-check"));
const visibleKeys = new Set(checkboxes.map((box) => box.dataset.key));
for (const key of Array.from(selectedKeys)) {
if (!visibleKeys.has(key)) selectedKeys.delete(key);
}
for (const key of Object.keys(modelByKey)) {
if (!visibleKeys.has(key)) delete modelByKey[key];
}
checkboxes.forEach((box) => {
box.checked = selectedKeys.has(box.dataset.key);
});
syncCardModels();
updateCategorySelectAllState();
updateBulkToolbar();
}
// Show the per-card model override for any selected card. A model is always
// relevant because "Fix with Copilot" (and "Create issue" with Assign Copilot
// on) can start a session at any time. Reflect stored overrides so they
// survive re-renders (SSE rebuilds cards).
function syncCardModels() {
Array.from(document.querySelectorAll(".card")).forEach((card) => {
const key = card.dataset.key;
const wrap = card.querySelector(".card-model-wrap");
const select = card.querySelector(".card-model");
const show = selectedKeys.has(key);
if (wrap) wrap.style.display = show ? "" : "none";
if (select) select.value = modelByKey[key] || "";
});
}
// The toolbar model picker feeds "Fix with Copilot", so keep it visible
// whenever the toolbar shows and reflect per-card overrides.
function syncModelControls() {
const modelWrap = document.querySelector(".toolbar-model");
if (modelWrap) modelWrap.style.display = "";
syncCardModels();
renderTargetSummary();
}
function updateCategorySelectAllState() {
document.querySelectorAll(".category").forEach((section) => {
const categoryToggle = section.querySelector(".category-check");
const boxes = Array.from(section.querySelectorAll(".issue-check"));
if (!categoryToggle || boxes.length === 0) return;
const checked = boxes.filter((box) => box.checked).length;
categoryToggle.indeterminate = checked > 0 && checked < boxes.length;
categoryToggle.checked = checked === boxes.length;
});
}
function updateBulkToolbar() {
const button = document.getElementById("work-selected");
const fixButton = document.getElementById("fix-with-copilot");
// Count only STARTABLE selections. A card that is already queued/working/
// handed-off stays selectable, but both click handlers filter it out via
// startableSelectedKeys(); counting it here would enable the buttons with
// "(1)" while clicking silently does nothing. Disabled state must match.
const count = startableSelectedKeys().length;
if (button) {
button.disabled = count === 0;
button.textContent = "📝 Create issue (" + count + ")";
}
if (fixButton) {
fixButton.disabled = count === 0;
fixButton.textContent = "🔧 Fix with Copilot (" + count + ")";
}
const modelSelect = document.getElementById("toolbar-model");
if (modelSelect) modelSelect.disabled = count === 0;
renderTargetSummary();
}
function renderTargetSummary() {
const node = document.getElementById("work-target-summary");
if (!node) return;
const trackers = Array.isArray(currentIssueTrackers) ? currentIssueTrackers : [];
const selected = trackers.find((tracker) => tracker.id === currentSelectedTracker) || trackers[0] || { label: "GitHub Issues", id: "github" };
const issueRepo = (currentPrTargets && currentPrTargets.cloud && currentPrTargets.cloud.repo) || "current repo";
const openIssue = selected.id === "github"
? (selected.label + " in " + issueRepo)
: selected.label;
const draftMode = currentPrTargets?.mode === "cloud" ? "cloud" : "local";
let draftTarget;
if (draftMode === "cloud") {
draftTarget = (currentPrTargets?.cloud?.repo || "current repo") + " @ " + (currentPrTargets?.cloud?.baseBranch || "default");
} else {
const projName = currentPrTargets?.local?.projectName || "";
const projId = currentPrTargets?.local?.projectId || "";
const localBase = currentPrTargets?.local?.baseBranch || "default";
if (projId) {
draftTarget = (projName || projId) + " @ " + localBase;
} else {
draftTarget = (currentPrTargets?.local?.path || "current project") + " @ " + localBase;
}
}
const modelId = currentPrTargets?.model || "";
const models = Array.isArray(currentAvailableModels) ? currentAvailableModels : [];
const modelLabel = (models.find((m) => m.id === modelId) || {}).label || "Auto";
let overrideCount = 0;
selectedKeys.forEach((key) => { if (modelByKey[key]) overrideCount++; });
const modelPart = overrideCount > 0
? modelLabel + " (" + overrideCount + " overridden)"
: modelLabel;
node.innerHTML = "";
const line1 = document.createElement("div");
line1.className = "summary-line";
line1.textContent = "📝 Create issue → tracking only in " + openIssue;
const line2 = document.createElement("div");
line2.className = "summary-line";
line2.textContent = "🔧 Fix with Copilot → opens issue in " + openIssue + " + Draft PR " + draftMode + " (" + draftTarget + ") · Model: " + modelPart;
node.appendChild(line1);
node.appendChild(line2);
}
function updateModeHint() {
const mode = document.getElementById("pr-mode");
const hint = document.getElementById("mode-hint");
const localGroup = document.getElementById("local-group");
const cloudGroup = document.getElementById("cloud-group");
const isCloud = mode && mode.value === "cloud";
if (hint) {
hint.textContent = isCloud
? "Cloud: hands the fix to a GitHub-hosted Copilot coding agent that creates the branch and draft PR remotely in the repo below. Nothing runs on your machine."
: "Local: opens the draft PR in a git worktree on this machine, using the local path and base branch below.";
}
if (localGroup) localGroup.classList.toggle("dimmed", !!isCloud);
if (cloudGroup) cloudGroup.classList.toggle("dimmed", !isCloud);
}
function syncSettingsFromState() {
const trackerSelect = document.getElementById("tracker-select");
if (trackerSelect) {
trackerSelect.innerHTML = "";
const trackers = Array.isArray(currentIssueTrackers) ? currentIssueTrackers : [];
trackers.forEach((tracker) => {
const option = document.createElement("option");
option.value = tracker.id;
option.textContent = tracker.connected === false
? (tracker.label + " (not connected)")
: tracker.label;
option.disabled = tracker.connected === false;
option.selected = tracker.id === currentSelectedTracker;
trackerSelect.appendChild(option);
});
}
const mode = document.getElementById("pr-mode");
if (mode) mode.value = currentPrTargets?.mode === "cloud" ? "cloud" : "local";
updateModeHint();
const modelSelect = document.getElementById("toolbar-model");
if (modelSelect) {
const selectedModel = currentPrTargets?.model || "";
const models = Array.isArray(currentAvailableModels) ? currentAvailableModels : [];
modelSelect.innerHTML = "";
const list = models.length ? models : [{ id: "", label: "Auto (session default)" }];
list.forEach((model) => {
const option = document.createElement("option");
option.value = model.id;
option.textContent = model.label;
option.selected = model.id === selectedModel;
modelSelect.appendChild(option);
});
modelSelect.value = selectedModel;
}
syncCardModels();
syncModelControls();
const localProject = document.getElementById("local-project");
if (localProject) {
const selectedId = currentPrTargets?.local?.projectId || "";
const options = Array.isArray(currentProjectOptions) ? currentProjectOptions : [];
localProject.innerHTML = "";
const base = document.createElement("option");
base.value = "";
base.textContent = "Current project (default)";
localProject.appendChild(base);
options.forEach((p) => {
const option = document.createElement("option");
option.value = p.id;
option.textContent = p.repo ? (p.name + " (" + p.repo + ")") : p.name;
option.selected = p.id === selectedId;
localProject.appendChild(option);
});
localProject.value = selectedId;
}
const localPath = document.getElementById("local-path");
if (localPath) localPath.value = currentPrTargets?.local?.path || "";
const localBranch = document.getElementById("local-branch");
if (localBranch) localBranch.value = currentPrTargets?.local?.baseBranch || "";
const cloudRepo = document.getElementById("cloud-repo");
if (cloudRepo) cloudRepo.value = currentPrTargets?.cloud?.repo || "";
const cloudBranch = document.getElementById("cloud-branch");
if (cloudBranch) cloudBranch.value = currentPrTargets?.cloud?.baseBranch || "";
const panel = document.getElementById("work-settings");
if (panel) panel.style.display = (!currentOrg || currentPrSettingsOpen) ? "" : "none";
renderTargetSummary();
}
source.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.flash) {
const icon = msg.flash.kind === "warn" ? "⚠️ " : "";
showToast(icon + msg.flash.message);
return;
}
if (typeof msg.scanning === "boolean") {
if (msg.scanning) showScanOverlay(currentProject ? "Scanning " + currentProject + "…" : "Scanning all projects…");
else hideScanOverlay();
return;
}
if (msg.work && msg.work.key) {
const next = { ...msg.work };
delete next.key;
workByIssue[msg.work.key] = next;
applyWorkStates();
return;
}
if (msg.connections) {
currentConnections = msg.connections;
applyConnectionState();
}
if (msg.org) {
if (msg.org !== currentOrg) resetPerCardState();
currentOrg = msg.org;
enterTriageChrome(msg.org);
updateToolbarVisibility(hasRenderableIssues());
} else if (typeof msg.orgDefault === "string") {
applyOrgDefault(msg.orgDefault);
}
if ("project" in msg) {
const nextProject = msg.project || "";
if (nextProject !== currentProject) resetPerCardState();
currentProject = nextProject;
const sel = document.getElementById("project-switcher");
if (sel) sel.value = currentProject;
updateTriageDesc();
}
if (typeof msg.period === "string") {
currentPeriod = msg.period;
const psel = document.getElementById("period-select");
if (psel && psel.value !== msg.period) psel.value = msg.period;
}
if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
if (Array.isArray(msg.projectOptions)) currentProjectOptions = msg.projectOptions;
if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
// Always cache under the org this list belongs to so re-selecting it is
// instant next time.
if (forOrg) 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;
setProjectLoading(false);
// Repaint whichever project field is on screen with the new options.
if (currentOrg) renderProjectSwitcher();
else renderSetupProjectField();
// If the user is actively typing in the project box while more
// projects stream in (large orgs page in over a few seconds),
// refresh the open suggestion menu live so new matches appear
// without needing another keystroke.
const activeInp = activeProjectInput();
if (activeInp && document.activeElement === activeInp) {
const wrap = activeInp.closest(".project-ac");
const menu = wrap && wrap.querySelector(".project-ac-menu");
if (menu && !menu.hidden) activeInp.dispatchEvent(new Event("input"));
}
}
}
if (Array.isArray(msg.orgOptions)) currentOrgOptions = msg.orgOptions;
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
}
if (Array.isArray(msg.availableModels)) currentAvailableModels = msg.availableModels;
if (typeof msg.plainEnglishView === "boolean" && msg.plainEnglishView !== currentPlainEnglishView) {
currentPlainEnglishView = msg.plainEnglishView;
syncTitleSwitch();
}
if (msg.prTargets) currentPrTargets = msg.prTargets;
if (msg.issueTrackers) currentIssueTrackers = msg.issueTrackers;
if (msg.selectedTracker) currentSelectedTracker = msg.selectedTracker;
if (typeof msg.prSettingsOpen === "boolean") currentPrSettingsOpen = msg.prSettingsOpen;
if (msg.workByIssueKey && typeof msg.workByIssueKey === "object") {
for (const key of Object.keys(workByIssue)) delete workByIssue[key];
Object.assign(workByIssue, msg.workByIssueKey);
}
syncSettingsFromState();
if (typeof msg.scanError === "string") currentScanError = msg.scanError;
if (typeof msg.scannedTotal === "number") currentScannedTotal = msg.scannedTotal;
if (typeof msg.scannedCapped === "boolean") currentScannedCapped = msg.scannedCapped;
if (typeof msg.scannedOlder === "number") currentScannedOlder = msg.scannedOlder;
if (typeof msg.scannedOlderCapped === "boolean") currentScannedOlderCapped = msg.scannedOlderCapped;
if (msg.categories === undefined) return;
currentCategories = Array.isArray(msg.categories) ? msg.categories : [];
renderCategories(currentCategories);
};
function updateToolbarVisibility(show) {
const toolbar = document.getElementById("work-toolbar");
if (toolbar) toolbar.style.display = show ? "" : "none";
const titleBar = document.getElementById("title-mode-bar");
if (titleBar) titleBar.style.display = show ? "" : "none";
}
// The Plain-English toggle and "Work on selected" bar only make sense when
// there's at least one triage-worthy issue on the board, so their visibility
// tracks the rendered issue count (not just whether an org is chosen).
function hasRenderableIssues() {
return Array.isArray(currentCategories)
&& currentCategories.reduce((s, c) => s + (c && Array.isArray(c.issues) ? c.issues.length : 0), 0) > 0;
}
// Toggle the setup gate (Sentry) from the latest preflight result.
// Optimistic before the first check so nothing flashes in prematurely.
// Tracks whether Sentry was gated on the previous apply so that when the
// gate clears (the preflight re-runs each time the canvas is reopened) we
// can announce why the board appeared.
function applyConnectionState() {
const c = currentConnections || {};
const checked = Boolean(c.checked);
const sentryReady = !checked || (c.sentry && c.sentry.reachable);
const gatedNow = checked && !sentryReady;
document.body.classList.toggle("sentry-gated", gatedNow);
// Auth vs connectivity vs unknown: only auth failures set configured:false,
// so a gated-but-configured state means Sentry was simply unreachable. Among
// those, transient marks a retryable network blip; a settled/unknown failure
// gets neutral guidance instead of a false "it should recover" promise. Swap
// the gate title/body so we never tell an already-signed-in user to run the
// sentry auth login command, nor promise recovery for what won't self-heal.
const signedOut = !(c.sentry && c.sentry.configured);
const transientConn = Boolean(c.sentry && c.sentry.transient);
const gateTitleEl = document.getElementById("gate-title");
if (gateTitleEl) {
gateTitleEl.textContent = signedOut
? "Connect Sentry to start triaging"
: "Cant reach Sentry right now";
}
const authBody = document.getElementById("gate-body-auth");
const connBody = document.getElementById("gate-body-conn");
const unknownBody = document.getElementById("gate-body-unknown");
if (authBody) authBody.style.display = signedOut ? "" : "none";
if (connBody) connBody.style.display = !signedOut && transientConn ? "" : "none";
if (unknownBody) unknownBody.style.display = !signedOut && !transientConn ? "" : "none";
// Surface the specific preflight failure in the gate (or hide it when the
// error clears / there's nothing actionable beyond the sign-in steps).
const gateErrEl = document.getElementById("gate-error");
if (gateErrEl) {
const err = (c.sentry && c.sentry.error) || "";
if (gatedNow && err) {
gateErrEl.textContent = err;
gateErrEl.style.display = "";
} else {
gateErrEl.textContent = "";
gateErrEl.style.display = "none";
}
}
if (wasGatedPrev === true && !gatedNow) {
// Just healed — the preflight re-ran (on reopen or re-check) and Sentry
// is now reachable. Tell the user why the board appeared.
showToast("✅ Sentry connected — loading your issues");
}
wasGatedPrev = gatedNow;
}
function enterTriageChrome(org) {
const picker = document.querySelector(".org-picker");
if (picker) picker.style.display = "none";
const setupInstructions = document.getElementById("setup-instructions");
if (setupInstructions) setupInstructions.style.display = "none";
const setupScan = document.getElementById("setup-scan");
if (setupScan) setupScan.style.display = "none";
if (!document.getElementById("triage-desc")) {
const header = document.querySelector(".page-header");
const p = document.createElement("p");
p.id = "triage-desc";
p.className = "page-description";
header.after(p);
if (!document.querySelector(".header-controls")) {
const controls = document.createElement("div");
controls.className = "header-controls";
header.appendChild(controls);
}
if (!document.getElementById("refresh")) {
const controls = document.querySelector(".header-controls");
const divider = document.createElement("span");
divider.className = "control-divider";
divider.setAttribute("aria-hidden", "true");
const btn = document.createElement("button");
btn.id = "refresh";
btn.className = "refresh-btn";
btn.title = "Re-scan errors";
btn.innerHTML = '<span class="refresh-icon">↻</span> Re-scan';
controls.appendChild(divider);
controls.appendChild(btn);
}
}
updateTriageDesc();
renderProjectSwitcher();
renderPeriodSwitcher();
syncTitleSwitch();
}
// Rebuild the triage description line with the current org + project scope.
function updateTriageDesc() {
const desc = document.getElementById("triage-desc");
if (!desc || !currentOrg) return;
let html = 'On-call error triage for <strong>' + escapeHtml(currentOrg) + '</strong>';
if (currentProject) html += ' / <strong>' + escapeHtml(currentProject) + '</strong>';
html += ' — issues grouped by why they need your attention right now.';
desc.innerHTML = html;
}
// Sentry project slugs to offer in the autocomplete, always including the
// current project so an explicitly-set slug is never dropped.
function sentryProjectChoices() {
const list = Array.isArray(currentSentryProjects) ? currentSentryProjects.filter(Boolean) : [];
return currentProject && !list.includes(currentProject) ? [currentProject, ...list] : list;
}
// The project input on screen — the header switcher in triage chrome, else
// the setup-screen field.
function activeProjectInput() {
return document.getElementById("project-switcher") || document.getElementById("project-input");
}
// The org the UI is currently targeting, lowercased. In triage chrome that's
// the header org switcher (or the scanned org); on the setup screen it's the
// org select / typed slug. Used to ignore stale project broadcasts from a
// previously-selected org (a background refresh can land after the user has
// already switched away).
function selectedOrgSlug() {
const sw = document.getElementById("org-switcher");
if (sw && sw.value.trim()) return sw.value.trim().toLowerCase();
const sel = document.getElementById("org-select");
if (sel && sel.value.trim()) return sel.value.trim().toLowerCase();
const inp = document.getElementById("org-input");
if (inp && inp.value.trim()) return inp.value.trim().toLowerCase();
return (currentOrg || "").trim().toLowerCase();
}
// Show/clear a "loading projects…" hint on the project field so switching
// org gives instant feedback while the new list is fetched over the network.
function setProjectLoading(on) {
const el = activeProjectInput();
if (!el) return;
if (on) {
el.classList.add("project-ac-loading");
el.setAttribute("placeholder", "loading projects…");
} else {
el.classList.remove("project-ac-loading");
el.setAttribute("placeholder", "all projects — type to search");
}
}
// Load the project list for an org into the autocomplete. Uses the per-org
// cache for an instant result when we've seen the org before; otherwise
// shows the loading hint until the SSE broadcast arrives. Always kicks off a
// background refresh so the cached list stays current.
function requestProjectsForOrg(org) {
const slug = String(org || "").trim().toLowerCase();
if (!slug) { currentSentryProjects = []; return; }
const cached = projectsByOrg[slug];
if (Array.isArray(cached)) {
currentSentryProjects = cached;
setProjectLoading(false);
} else {
currentSentryProjects = [];
setProjectLoading(true);
}
fetch("/api/list-projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org: slug })
}).catch(() => setProjectLoading(false));
}
// Wire a text input as a project autocomplete: a filtered suggestion menu.
// (A <select> is unwieldy for orgs with many projects, and a <datalist>
// does not repaint in this webview.) onCommit(value) runs when the user
// picks a suggestion; an empty value means "all projects". The project list
// is read live on each open, so newly-discovered projects appear without a
// rebuild. Idempotent per input.
function attachProjectAutocomplete(input, onCommit) {
if (!input || input.dataset.acWired) return;
input.dataset.acWired = "1";
// Ensure the input sits in a positioned wrapper with a menu container.
let wrap = input.closest(".project-ac");
if (!wrap) {
wrap = document.createElement("span");
wrap.className = "project-ac";
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
}
let menu = wrap.querySelector(".project-ac-menu");
if (!menu) {
menu = document.createElement("div");
menu.className = "project-ac-menu";
menu.setAttribute("role", "listbox");
menu.hidden = true;
wrap.appendChild(menu);
}
// Programmatically associate the combobox with its listbox so screen
// readers announce the popup and can follow the active option.
if (!menu.id) menu.id = (input.id ? input.id : "project-ac") + "-listbox";
input.setAttribute("aria-controls", menu.id);
const optionId = (i) => menu.id + "-opt-" + i;
function syncActiveDescendant() {
if (active >= 0 && items[active]) input.setAttribute("aria-activedescendant", optionId(active));
else input.removeAttribute("aria-activedescendant");
}
let items = [];
let active = -1;
const CAP = 50;
function render() {
const q = input.value.trim().toLowerCase();
const source = sentryProjectChoices();
let matches = q ? source.filter((s) => s.toLowerCase().includes(q)) : source.slice();
const truncated = matches.length > CAP;
matches = matches.slice(0, CAP);
items = [];
// Offer an explicit "all projects" reset only when the box is empty.
if (!q) items.push({ value: "", label: "All projects", all: true });
matches.forEach((s) => items.push({ value: s, label: s }));
if (!items.length) {
menu.innerHTML = '<div class="project-ac-empty">No matching projects</div>';
syncActiveDescendant();
return;
}
if (active >= items.length) active = items.length - 1;
menu.innerHTML =
items
.map((it, i) =>
'<div id="' + optionId(i) + '" class="project-ac-item' + (i === active ? ' active' : '') + '" role="option" aria-selected="' + (i === active) + '" data-idx="' + i + '">' +
(it.all ? '<span class="project-ac-all">' + escapeHtml(it.label) + '</span>' : escapeHtml(it.label)) +
'</div>')
.join('') +
(truncated ? '<div class="project-ac-more">Keep typing to narrow…</div>' : '');
syncActiveDescendant();
}
function open() { render(); menu.hidden = false; input.setAttribute("aria-expanded", "true"); }
function close() { menu.hidden = true; active = -1; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); }
function commit(value) {
input.value = value;
close();
if (onCommit) onCommit(value);
}
input.addEventListener("focus", open);
input.addEventListener("input", () => { active = -1; open(); });
input.addEventListener("keydown", (e) => {
if (menu.hidden) {
if (e.key === "ArrowDown") { e.preventDefault(); open(); }
return;
}
if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, items.length - 1); render(); }
else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); }
else if (e.key === "Enter") {
// Only intercept Enter when a suggestion is highlighted; otherwise let
// it bubble to the header handler that applies a typed value.
if (active >= 0 && items[active]) { e.preventDefault(); e.stopPropagation(); commit(items[active].value); }
} else if (e.key === "Escape") { e.stopPropagation(); close(); }
});
// mousedown (not click) so the selection beats the input's blur.
menu.addEventListener("mousedown", (e) => {
const el = e.target.closest(".project-ac-item");
if (!el) return;
e.preventDefault();
const idx = Number(el.dataset.idx);
if (items[idx]) commit(items[idx].value);
});
input.addEventListener("blur", () => { setTimeout(close, 120); });
}
// Ensure the setup-screen project field behaves as an autocomplete. The
// suggestion list is read live on each open, so no rebuild is needed when
// the org's projects arrive. Picking here just fills the box; the Scan
// button reads #project-input on submit.
function renderSetupProjectField() {
const el = document.getElementById("project-input");
if (el) attachProjectAutocomplete(el, null);
}
// Ensure the project control exists in the header and reflects the current
// project. Renders as an autocomplete combobox (a text input + filtered
// suggestion menu); picking a suggestion re-scans that project, and a typed
// value applies via the Fetch button or Enter. Injected client-side when
// the page first rendered from the org picker (no header controls yet).
function renderProjectSwitcher() {
if (!currentOrg) return;
let controls = document.querySelector(".header-controls");
if (!controls) {
const header = document.querySelector(".page-header");
if (!header) return;
controls = document.createElement("div");
controls.className = "header-controls";
header.appendChild(controls);
}
// Org + Project live together in a tight scope cluster, set apart from the
// time range / re-scan controls.
let scope = controls.querySelector(".scope-switchers");
if (!scope) {
scope = document.createElement("span");
scope.className = "scope-switchers";
controls.insertBefore(scope, controls.firstChild);
}
let input = document.getElementById("project-switcher");
if (!input) {
const label = document.createElement("label");
label.className = "project-switcher";
const span = document.createElement("span");
span.className = "project-switcher-label";
span.textContent = "Project";
const wrap = document.createElement("span");
wrap.className = "project-ac";
input = document.createElement("input");
input.id = "project-switcher";
input.className = "project-select";
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-expanded", "false");
input.setAttribute("placeholder", "all projects — type to search");
input.setAttribute("autocomplete", "off");
input.setAttribute("autocapitalize", "none");
input.setAttribute("autocorrect", "off");
input.setAttribute("spellcheck", "false");
wrap.appendChild(input);
const fetchBtn = document.createElement("button");
fetchBtn.id = "project-fetch";
fetchBtn.className = "project-fetch-btn";
fetchBtn.type = "button";
fetchBtn.title = "Fetch tickets for this project";
fetchBtn.textContent = "Fetch";
label.appendChild(span);
label.appendChild(wrap);
label.appendChild(fetchBtn);
scope.insertBefore(label, scope.firstChild);
}
input.value = currentProject || "";
attachProjectAutocomplete(input, () => fetchScoped());
// Org control, injected before the project switcher so the header reads
// Org · Project · Fetch. Shares the project's Fetch button. Renders as a
// dropdown when 2+ orgs are available (matching the setup screen), else a
// text input.
let orgInput = document.getElementById("org-switcher");
if (!orgInput) {
const orgLabel = document.createElement("label");
orgLabel.className = "project-switcher";
const orgSpan = document.createElement("span");
orgSpan.className = "project-switcher-label";
orgSpan.textContent = "Org";
const orgSlugs = Array.isArray(currentOrgOptions) ? currentOrgOptions.filter(Boolean) : [];
const slugs = (!currentOrg || orgSlugs.includes(currentOrg)) ? orgSlugs : [currentOrg, ...orgSlugs];
if (slugs.length >= 2) {
orgInput = document.createElement("select");
orgInput.id = "org-switcher";
orgInput.className = "project-select org-switcher-select";
orgInput.title = "Sentry organizations you can access";
slugs.forEach((slug) => {
const opt = document.createElement("option");
opt.value = slug;
opt.textContent = slug;
if (slug === currentOrg) opt.selected = true;
orgInput.appendChild(opt);
});
} else {
orgInput = document.createElement("input");
orgInput.id = "org-switcher";
orgInput.className = "project-select";
orgInput.setAttribute("placeholder", "org slug");
orgInput.setAttribute("autocomplete", "off");
orgInput.setAttribute("autocapitalize", "none");
orgInput.setAttribute("autocorrect", "off");
orgInput.setAttribute("spellcheck", "false");
}
orgLabel.appendChild(orgSpan);
orgLabel.appendChild(orgInput);
const projectLabel = scope.querySelector(".project-switcher");
scope.insertBefore(orgLabel, projectLabel || scope.firstChild);
}
orgInput.value = currentOrg || "";
}
// Ensure the time-window <select> exists in the header (injected client-side
// when the page first rendered from the org picker) and reflects the current
// window. Inserted right after the project switcher so the header reads
// Project · Window · Re-scan.
function renderPeriodSwitcher() {
if (!currentOrg) return;
const controls = document.querySelector(".header-controls");
if (!controls) return;
let sel = document.getElementById("period-select");
if (!sel) {
const label = document.createElement("label");
label.className = "period-switcher";
const span = document.createElement("span");
span.className = "period-switcher-label";
span.textContent = "Time range";
sel = document.createElement("select");
sel.id = "period-select";
sel.className = "period-select";
sel.title = "How far back to search Sentry";
const list = Array.isArray(currentPeriods) ? currentPeriods : [];
list.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.value;
opt.textContent = p.label;
if (p.value === currentPeriod) opt.selected = true;
sel.appendChild(opt);
});
label.appendChild(span);
label.appendChild(sel);
// Anchor after the scope cluster (Org · Project · Fetch) so the header
// reads scope · Time range · Re-scan.
const scope = controls.querySelector(".scope-switchers");
if (scope) {
const divider = document.createElement("span");
divider.className = "control-divider";
divider.setAttribute("aria-hidden", "true");
controls.insertBefore(divider, scope.nextSibling);
controls.insertBefore(label, divider.nextSibling);
} else {
controls.insertBefore(label, controls.firstChild);
}
}
sel.value = currentPeriod;
}
// Canvas-wide raw/plain title toggle. A literal switch that lives above the
// 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";
}
// Flip the whole canvas between raw error and plain-English titles. Optimistic:
// update locally and re-render every card immediately, then persist so the
// choice survives re-scans and reloads. Display-only — no re-scan.
document.addEventListener("change", (e) => {
const box = e.target.closest("#title-mode-switch");
if (!box) return;
currentPlainEnglishView = box.checked;
syncTitleSwitch();
renderCategories(currentCategories);
fetch("/api/toggle-title-mode", { method: "POST" });
});
// Re-scan for the current org + project entered in the header. Empty project
// = all projects; empty org is not allowed (org is required to scan). Shows
// optimistic scanning state; the server validates unknown slugs. Only ever
// called from an explicit user action (Fetch button or Enter) — never on
// blur/typing, so we don't scan until the user asks.
function fetchScoped() {
const orgInput = document.getElementById("org-switcher");
const projectInput = document.getElementById("project-switcher");
const org = orgInput ? orgInput.value.trim() : currentOrg;
const project = projectInput ? projectInput.value.trim() : "";
if (!org) { if (orgInput) orgInput.focus(); return; }
if (org !== currentOrg || project !== currentProject) resetPerCardState();
// Snapshot the last-scanned scope BEFORE the optimistic update so a failed
// rescan can roll back. Without this, a network/server failure leaves the
// switchers showing the new org/project while the still-rendered cards
// belong to the old scope — stale data under a selection we never scanned.
const prevOrg = currentOrg;
const prevProject = currentProject;
const subtitle = document.querySelector(".page-subtitle");
const prevSubtitle = subtitle ? subtitle.textContent : "";
currentOrg = org;
currentProject = project;
updateTriageDesc();
if (subtitle) subtitle.textContent = "Scanning " + (project || "all projects") + "...";
showScanOverlay("Scanning " + (project || "all projects") + "…");
fetch("/api/set-org", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org, project })
}).then((res) => {
if (!res.ok) throw new Error("set-org " + res.status);
}).catch(() => {
// Loopback POST failed (server stopped/rejected, or a transport blip).
// Roll the optimistic selection back to the last-scanned scope so the
// switchers stay consistent with the cards still on screen, clear the
// overlay (which would otherwise linger until its safety timer), and
// tell the user so they can retry.
hideScanOverlay();
currentOrg = prevOrg;
currentProject = prevProject;
if (orgInput) orgInput.value = prevOrg;
if (projectInput) projectInput.value = prevProject;
updateTriageDesc();
if (subtitle) subtitle.textContent = prevSubtitle;
window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again.");
});
}
// Enter in the org or project input triggers the same fetch as clicking
// Fetch, so there's still a keyboard path — but nothing fetches on blur or
// typing.
document.addEventListener("keydown", (e) => {
const input = e.target.closest("#project-switcher, #org-switcher");
if (!input) return;
if (e.key === "Enter") { e.preventDefault(); fetchScoped(); }
});
// Explicit "Fetch" button: the only pointer affordance for re-scanning the
// entered org + project.
document.addEventListener("click", (e) => {
const btn = e.target.closest("#project-fetch");
if (!btn) return;
fetchScoped();
});
// The header org control is a <select> when 2+ orgs exist; a selection is an
// explicit action, so re-scan immediately. (The project control is an
// autocomplete input — it re-scans via its own commit handler, not here.)
document.addEventListener("change", (e) => {
const orgSel = e.target.closest("#org-switcher");
if (orgSel && orgSel.tagName === "SELECT") {
// A new org invalidates the old project. Clear the project input to all
// projects and re-scan; also refresh the autocomplete list for the new
// org (instant from cache, else a loading hint) so it's ready by the time
// the scan lands. Leave currentProject alone here — fetchScoped commits it
// from the (now empty) input and snapshots the prior value for rollback,
// so a failed rescan restores the old project rather than clearing it.
const projInput = document.getElementById("project-switcher");
if (projInput) projInput.value = "";
requestProjectsForOrg(orgSel.value);
fetchScoped();
}
});
// Changing the time window re-scans over the new period. Native <select>
// change fires on selection; show the scan overlay optimistically.
document.addEventListener("change", (e) => {
const sel = e.target.closest("#period-select");
if (!sel) return;
// Snapshot the prior period before the optimistic update so a failed
// rescan can roll the <select> and state back, keeping the control aligned
// with the cards still on screen (which belong to the old period).
const prevPeriod = currentPeriod;
const subtitle = document.querySelector(".page-subtitle");
const prevSubtitle = subtitle ? subtitle.textContent : "";
currentPeriod = sel.value;
showScanOverlay(currentProject ? "Scanning " + currentProject + "…" : "Scanning all projects…");
fetch("/api/set-period", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ period: sel.value })
}).then((res) => {
if (!res.ok) throw new Error("set-period " + res.status);
}).catch(() => {
hideScanOverlay();
currentPeriod = prevPeriod;
sel.value = prevPeriod;
if (subtitle) subtitle.textContent = prevSubtitle;
window.alert("Couldn't change the time range — the triage server may have stopped responding. Please try again.");
});
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("#refresh, #rescan");
if (!btn) return;
fetch("/api/refresh", { method: "POST" });
const subtitle = document.querySelector(".page-subtitle");
if (subtitle) subtitle.textContent = "Scanning Sentry...";
showScanOverlay(currentProject ? "Scanning " + currentProject + "…" : "Scanning all projects…");
});
// Both "Create issue" and "Fix with Copilot" optimistically paint their
// cards as "queued" before the server confirms. If the POST is rejected
// (loopback server down) or returns non-2xx, no SSE update will ever
// arrive to correct them, so the cards would sit as "working…" forever.
// Flip any card still in the optimistic "queued" state to a retryable
// error; leave cards an SSE update already advanced (working/handed-off/…).
function failQueuedWork(keys, message) {
let changed = false;
keys.forEach((key) => {
const cur = workByIssue[key];
if (cur && cur.phase === "queued") {
workByIssue[key] = { phase: "error", error: message };
changed = true;
}
});
if (changed) applyWorkStates();
showToast("⚠️ " + message);
}
function postWorkSelected(keys, payload) {
fetch("/api/work-selected", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then((res) => {
if (!res || !res.ok) {
const code = res && typeof res.status === "number" ? res.status : "?";
failQueuedWork(keys, "Couldn't start work (server returned " + code + "). Retry.");
}
}).catch(() => {
failQueuedWork(keys, "Couldn't reach the local canvas server. Retry.");
});
}
document.addEventListener("click", (e) => {
const btn = e.target.closest("#work-selected");
if (!btn || selectedKeys.size === 0) return;
const keys = startableSelectedKeys();
if (keys.length === 0) return;
keys.forEach((key) => {
workByIssue[key] = { phase: "queued" };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
showToast("📝 Filing tracking issues for " + keys.length + " issues…");
postWorkSelected(keys, { keys, modelByKey: {}, assignCopilot: false });
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("#fix-with-copilot");
if (!btn || selectedKeys.size === 0) return;
const keys = startableSelectedKeys();
if (keys.length === 0) return;
const models = {};
keys.forEach((key) => {
if (modelByKey[key]) models[key] = modelByKey[key];
workByIssue[key] = { phase: "queued" };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
showToast("🔧 Creating/reusing issues and starting Copilot for " + keys.length + " issues…");
postWorkSelected(keys, { keys, modelByKey: models, assignCopilot: true });
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("#toggle-pr-settings");
if (!btn) return;
fetch("/api/toggle-pr-settings", { method: "POST" });
});
document.addEventListener("change", (e) => {
if (e.target && e.target.id === "pr-mode") updateModeHint();
if (e.target && e.target.id === "local-project") {
const id = e.target.value || "";
const match = (Array.isArray(currentProjectOptions) ? currentProjectOptions : []).find((p) => p.id === id);
const localPath = document.getElementById("local-path");
if (localPath) localPath.value = match && match.path ? match.path : "";
// Keep the base branch in step with the selected project. Leaving the
// previous project's branch here would pair the new projectId with a
// stale (possibly nonexistent) base on save; clearing it falls back to
// this project's own default branch.
const localBranch = document.getElementById("local-branch");
if (localBranch) localBranch.value = match && match.defaultBranch ? match.defaultBranch : "";
}
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("#save-pr-config");
if (!btn) return;
const localProjectEl = document.getElementById("local-project");
const localProjectId = localProjectEl?.value || "";
let localProjectName = "";
if (localProjectId) {
const match = (Array.isArray(currentProjectOptions) ? currentProjectOptions : []).find((p) => p.id === localProjectId);
localProjectName = match ? match.name : "";
}
const payload = {
mode: document.getElementById("pr-mode")?.value || "local",
model: document.getElementById("toolbar-model")?.value || "",
localPath: document.getElementById("local-path")?.value || "",
localBranch: document.getElementById("local-branch")?.value || "",
localProjectId: localProjectId,
localProjectName: localProjectName,
cloudRepo: document.getElementById("cloud-repo")?.value || "",
cloudBranch: document.getElementById("cloud-branch")?.value || "",
};
const workBtn = document.getElementById("work-selected");
const fixBtn = document.getElementById("fix-with-copilot");
// Block the save button + both work actions until the write settles, so a
// user can't click "Fix with Copilot" and start work against the previous
// repo/path/branch while this POST is still in flight.
btn.disabled = true;
if (workBtn) workBtn.disabled = true;
if (fixBtn) fixBtn.disabled = true;
fetch("/api/set-pr-config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
showToast("Saved draft PR config");
})
.catch(() => { showToast("Couldn't save draft PR config — check your targets and try again"); })
.finally(() => {
btn.disabled = false;
// Restore the work actions from the live selection (not a pre-save
// snapshot) so any selection change during the save is reflected.
updateBulkToolbar();
});
});
document.addEventListener("change", (e) => {
const issueCheck = e.target.closest(".issue-check");
if (issueCheck) {
const key = issueCheck.dataset.key;
if (!key) return;
if (issueCheck.checked) selectedKeys.add(key);
else selectedKeys.delete(key);
syncCardModels();
updateCategorySelectAllState();
updateBulkToolbar();
return;
}
const cardModel = e.target.closest(".card-model");
if (cardModel) {
const key = cardModel.dataset.key;
if (!key) return;
if (cardModel.value) modelByKey[key] = cardModel.value;
else delete modelByKey[key];
renderTargetSummary();
return;
}
if (e.target && e.target.id === "toolbar-model") {
const value = e.target.value || "";
if (currentPrTargets) currentPrTargets.model = value;
renderTargetSummary();
fetch("/api/set-pr-config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: value })
});
return;
}
const categoryCheck = e.target.closest(".category-check");
if (categoryCheck) {
const categoryId = categoryCheck.dataset.category;
if (!categoryId) return;
const section = document.querySelector('.category[data-category="' + categoryId + '"]');
if (!section) return;
section.querySelectorAll(".issue-check").forEach((box) => {
box.checked = categoryCheck.checked;
if (categoryCheck.checked) selectedKeys.add(box.dataset.key);
else selectedKeys.delete(box.dataset.key);
});
syncCardModels();
updateCategorySelectAllState();
updateBulkToolbar();
return;
}
const trackerSelect = e.target.closest("#tracker-select");
if (trackerSelect) {
fetch("/api/select-tracker", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tracker: trackerSelect.value })
});
return;
}
});
function showToast(msg) {
const toast = document.getElementById("toast");
// Make the polite live region visible first, then set its text, so the
// text change happens while the region is present and screen readers
// announce it. Clear any prior hide timer so a rapid second toast still
// announces and stays up its full duration.
toast.style.display = "block";
toast.textContent = msg;
if (toast._hideTimer) clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => { toast.style.display = "none"; }, 3000);
}
let scanOverlayTimer = null;
function showScanOverlay(label) {
const overlay = document.getElementById("scan-overlay");
if (!overlay) return;
const text = document.getElementById("scan-overlay-text");
if (text) text.textContent = label || "Scanning…";
overlay.style.display = "flex";
// Safety valve: never trap the user behind the overlay if a stop signal
// is somehow missed. Slightly longer than the scan's server-side timeout.
if (scanOverlayTimer) clearTimeout(scanOverlayTimer);
scanOverlayTimer = setTimeout(hideScanOverlay, 250000);
}
function hideScanOverlay() {
const overlay = document.getElementById("scan-overlay");
if (overlay) overlay.style.display = "none";
if (scanOverlayTimer) { clearTimeout(scanOverlayTimer); scanOverlayTimer = null; }
}
function getOrCreateSubtitle() {
let subtitle = document.querySelector(".page-subtitle");
if (!subtitle) {
subtitle = document.createElement("p");
subtitle.className = "page-subtitle";
const desc = document.getElementById("triage-desc") || document.querySelector(".page-description");
if (desc) desc.after(subtitle);
else document.querySelector(".page-header").after(subtitle);
}
return subtitle;
}
// KEEP IN SYNC with scannedLabel/subtitleText/emptyText in page.mjs (server
// render). Same wording so the first paint and SSE re-renders never disagree.
function scannedLabelClient(total, capped) {
return capped ? String(total) + "+" : String(total);
}
function subtitleTextClient(shown) {
const total = currentScannedTotal;
if (total > shown) {
return "Top " + shown + " of " + scannedLabelClient(total, currentScannedCapped) +
" open issues, prioritized by impact";
}
return shown + " open issue" + (shown === 1 ? "" : "s") + " to triage";
}
function emptyTextClient() {
const total = currentScannedTotal;
const where = currentProject ? " for " + currentProject : "";
if (total > 0) {
return "✓ Scanned " + scannedLabelClient(total, currentScannedCapped) + " open issue" +
(total === 1 ? "" : "s") + where + " — none are urgent enough to surface right now.";
}
const older = currentScannedOlder;
if (older > 0) {
const one = older === 1 && !currentScannedOlderCapped;
return "✓ No open issues" + where + " in this window — Sentry has " +
scannedLabelClient(older, currentScannedOlderCapped) + " unresolved issue" +
(one ? "" : "s") + " in the last 90 days.";
}
return "✓ No open issues" + where + " in this window.";
}
function renderCategories(categories) {
const main = document.getElementById("categories");
const total = categories.reduce((s, c) => s + c.issues.length, 0);
// On the setup screen (no org chosen yet) an empty scan result is not a
// real "all clear" — it's just the pre-scan snapshot. Keep the empty
// state hidden so it never competes with the setup instructions.
if (!currentOrg) {
const emptyState = document.getElementById("empty-state");
if (emptyState) emptyState.style.display = "none";
if (main) main.innerHTML = "";
updateToolbarVisibility(false);
return;
}
if (total === 0) {
main.innerHTML = "";
getOrCreateSubtitle().style.display = "none";
updateToolbarVisibility(false);
const emptyState = document.getElementById("empty-state");
const emptyMsg = document.getElementById("empty-msg");
const emptyTips = document.getElementById("empty-tips");
if (emptyMsg) {
if (currentScanError) {
emptyMsg.textContent = "⚠️ " + currentScanError;
emptyState.classList.add("empty-state-error");
if (emptyTips) emptyTips.style.display = "none";
} else {
emptyMsg.textContent = emptyTextClient();
emptyState.classList.remove("empty-state-error");
if (emptyTips) emptyTips.style.display = "";
}
}
emptyState.style.display = "flex";
applySelections();
return;
}
const subtitle = getOrCreateSubtitle();
subtitle.textContent = subtitleTextClient(total) + (currentProject ? "" : " · all projects");
subtitle.style.display = "";
document.getElementById("empty-state").style.display = "none";
updateToolbarVisibility(true);
main.innerHTML = categories
.filter((c) => c.issues.length > 0)
.map((c) => categoryHTML(c))
.join("");
applySelections();
applyWorkStates();
}
const CATEGORY_DESCRIPTIONS = {
"regressions": "Issues that previously existed, were resolved, and have resurfaced — likely tied to a recent release.",
"escalating": "Issues Sentry flagged as escalating, plus older high-impact issues affecting many users or generating many events.",
"new-critical": "Brand new issues that have already hit multiple users within hours of first appearing."
};
function categoryHTML(cat) {
const desc = CATEGORY_DESCRIPTIONS[cat.id] || "";
return '<section class="category" data-category="' + escapeHtml(cat.id) + '">'
+ '<div class="category-header"><h2>' + escapeHtml(cat.name) + ' <span class="badge">' + cat.issues.length + '</span></h2>'
+ '<label class="category-select-all"><input type="checkbox" class="category-check" data-category="' + escapeHtml(cat.id) + '" /> Select all</label>'
+ (desc ? '<p class="category-description">' + desc + '</p>' : '')
+ '</div>'
+ '<div class="card-list">' + cat.issues.map(cardHTML).join("") + '</div>'
+ '</section>';
}
function cardModelOptionsHtml(selected) {
const models = Array.isArray(currentAvailableModels) ? currentAvailableModels : [];
const nonDefault = models.filter((model) => model.id);
const chosen = selected || "";
const opts = ['<option value=""' + (chosen ? '' : ' selected') + '>Default</option>'];
nonDefault.forEach((model) => {
opts.push('<option value="' + escapeHtml(model.id) + '"' + (model.id === chosen ? ' selected' : '') + '>' + escapeHtml(model.label) + '</option>');
});
return opts.join("");
}
function cardHTML(issue) {
const href = safeHref(issue.url);
const key = escapeHtml(issue.key);
const meta = [
issue.events != null ? Number(issue.events).toLocaleString() + " events" : null,
issue.users != null ? Number(issue.users).toLocaleString() + " users" : null
].filter(Boolean).join(" · ");
const workStatus = workByIssue[issue.key];
const workText = workStatusHtml(workStatus);
const workClass = statusClass(workStatus);
const modelSelected = modelByKey[issue.key] || "";
return '<div class="card" data-key="' + key + '">'
+ '<label class="card-checkbox" title="Select this issue"><input type="checkbox" class="issue-check" data-key="' + key + '" aria-label="Select issue ' + key + '" /></label>'
+ '<div class="card-link">'
+ '<div class="card-header">'
+ '<a href="' + href + '" target="_blank" rel="noopener noreferrer" class="card-key">' + key + '</a>'
+ '<span class="card-summary">' + escapeHtml(currentPlainEnglishView ? (issue.plainEnglish || issue.summary) : issue.summary) + '</span>'
+ '</div>'
+ '<span class="card-reason">' + escapeHtml(issue.reason) + '</span>'
+ (meta ? '<span class="card-meta">' + meta + '</span>' : '')
+ '<span class="card-work-status ' + workClass + '" data-key="' + key + '"' + (workText ? '' : ' style="display:none;"') + '>' + workText + '</span>'
+ relatedHintHtml(issue)
+ '</div>'
+ '<label class="card-model-wrap" data-key="' + key + '" style="display:none;" title="Model for this issue&#39;s fix session — Default uses the model chosen above the list">'
+ '<span class="card-model-label">Model</span>'
+ '<select class="card-model" data-key="' + key + '">' + cardModelOptionsHtml(modelSelected) + '</select>'
+ '</label>'
+ '</div>';
}
</script>
</body>
</html>`
}