From 3d35fad5fce8028bc11daa4152e32a8d306709f7 Mon Sep 17 00:00:00 2001 From: Pierce Boggan Date: Sun, 25 Oct 2026 23:37:08 -0600 Subject: [PATCH] Update sentry-triage canvas to 1.3.0 Sync the canvas extension with pierceboggan/sentry-canvas (natalierharris-polish-sentry-triage-canvas): paged issue fetching, an on-demand issue detail view, a roomier layout, and event trend sparklines plus an overview chart. Bump the plugin to 1.3.0 and note the changes in the plugin README. Co-authored-by: Natalie Harris Co-authored-by: Pierce Boggan Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 2 +- extensions/sentry-triage/components/card.mjs | 55 +- .../sentry-triage/components/category.mjs | 45 +- extensions/sentry-triage/components/page.mjs | 1040 ++++++--- extensions/sentry-triage/extension.mjs | 2 + extensions/sentry-triage/sentry.mjs | 29 +- extensions/sentry-triage/sentryClient.mjs | 242 +- extensions/sentry-triage/server.mjs | 29 +- extensions/sentry-triage/state.mjs | 2 +- extensions/sentry-triage/styles.mjs | 2057 ++++++++++------- plugins/sentry-triage/README.md | 7 + plugins/sentry-triage/plugin.json | 2 +- 12 files changed, 2315 insertions(+), 1197 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index f87ce7557b..bec60902ca 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -2211,7 +2211,7 @@ "name": "sentry-triage", "source": "plugins/sentry-triage", "description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.", - "version": "1.2.0" + "version": "1.3.0" }, { "name": "signals-dashboard", diff --git a/extensions/sentry-triage/components/card.mjs b/extensions/sentry-triage/components/card.mjs index b8c9259fc2..67873bc27d 100644 --- a/extensions/sentry-triage/components/card.mjs +++ b/extensions/sentry-triage/components/card.mjs @@ -1,21 +1,30 @@ import { escapeHtml, safeHref } from '../escape.mjs' +// KEEP IN SYNC with compactNumber in page.mjs's client script. +function compactNumber(value) { + const n = Number(value) || 0 + if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M' + if (n >= 1e4) return Math.round(n / 1e3) + 'k' + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k' + return String(n) +} + function statusLabel(workStatus) { if (!workStatus || typeof workStatus !== 'object') return '' if (workStatus.phase === 'working' || workStatus.phase === 'queued') { - return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…' + return workStatus.copilotFix ? 'Starting Copilot fix…' : 'Filing tracking issue…' } if (workStatus.phase === 'done') { // Numbers move into clickable links (statusLinks); keep the label a plain badge. - return 'created ✓' + return 'Created' } if (workStatus.phase === 'handed-off') { - const sessionPart = workStatus.sessionName ? `🧵 ${workStatus.sessionName}` : '🧵 session started' + const sessionPart = workStatus.sessionName ? workStatus.sessionName : 'Session started' return `${sessionPart} ↗` } - if (workStatus.phase === 'skipped') return '🔒 already being worked on' - if (workStatus.phase === 'tracked') return '👀 Tracked' - if (workStatus.phase === 'error') return `⚠️ ${workStatus.error || 'work failed'}` + if (workStatus.phase === 'skipped') return 'Already in progress' + if (workStatus.phase === 'tracked') return 'Tracked' + if (workStatus.phase === 'error') return workStatus.error || 'Work failed' return '' } @@ -60,7 +69,21 @@ function statusLinks(workStatus) { return links.join(' · ') } -export function Card({ key, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [] }) { +// KEEP IN SYNC with sparklineSvg in page.mjs's client script. +function sparklineSvg(trend) { + const values = (Array.isArray(trend) ? trend : []).map((v) => Number(v) || 0) + if (values.length < 2) return '' + const w = 72, h = 24, max = Math.max(1, ...values) + const pts = values.map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`) + return `` +} + +function trendAttrs(trend, trendStart, trendStep) { + const values = (Array.isArray(trend) ? trend : []).map((v) => Number(v) || 0) + return values.length ? ` data-trend="${values.join(',')}" data-trend-start="${Number(trendStart) || 0}" data-trend-step="${Number(trendStep) || 0}"` : '' +} + +export function Card({ key, category, categoryLabel, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [], trend, trendStart, trendStep }) { const href = safeHref(url) const safeKey = escapeHtml(key) const title = plainEnglishView ? (plainEnglish || summary) : summary @@ -80,9 +103,10 @@ export function Card({ key, summary, plainEnglish, reason, events, users, url, w ? 'working' : 'idle' const meta = [ - events != null ? `${Number(events).toLocaleString()} events` : null, - users != null ? `${Number(users).toLocaleString()} users` : null - ].filter(Boolean).join(' · ') + sparklineSvg(trend) || null, + events != null ? `${compactNumber(events)}events` : null, + users != null ? `${compactNumber(users)}users` : null + ].filter(Boolean).join('') // Per-card model override. Empty value means "use the toolbar/batch default". const cardModelOptions = [''] @@ -93,22 +117,19 @@ export function Card({ key, summary, plainEnglish, reason, events, users, url, w ) .join('') - return `
+ return `
+ ${meta ? `${meta}` : ''}
+ + + + ` } diff --git a/extensions/sentry-triage/extension.mjs b/extensions/sentry-triage/extension.mjs index 3abdf09a0b..c5be453c61 100644 --- a/extensions/sentry-triage/extension.mjs +++ b/extensions/sentry-triage/extension.mjs @@ -5,6 +5,7 @@ import { startServer } from './server.mjs' import { scanIssues, listOrgs, listProjects, findProject } from './sentry.mjs' import { checkConnections, checkConnectionsOnce, installDependencies, authenticate } from './preflight.mjs' import { sanitizeForPrompt } from './escape.mjs' +import { issueDetail, PRIORITY } from './sentryClient.mjs' // A Sentry-derived URL is safe to pass through only if it PARSES as a real // http(s) URL. Returning the canonical `url.href` (rather than the raw string) @@ -2145,6 +2146,7 @@ const session = await joinSession({ onAuthenticate: () => onAuthenticate(entry), onListProjects: (org) => discoverProjects(entry, org, { force: true }), onResolveProject: (org, slug) => resolveProject(entry, org, slug), + onIssueDetail: (org, key, { part, prefetch } = {}) => issueDetail(org, key, { part, priority: prefetch ? PRIORITY.prefetch : PRIORITY.interactive }), onInvalidateEnrichment: () => { // Bump the scan generation so any enrichment turn still in flight // from the previous repo fails its isCurrent() guard and applies diff --git a/extensions/sentry-triage/sentry.mjs b/extensions/sentry-triage/sentry.mjs index fa0fc1425d..8af3e1c41a 100644 --- a/extensions/sentry-triage/sentry.mjs +++ b/extensions/sentry-triage/sentry.mjs @@ -93,6 +93,26 @@ export function mapIssue(raw, now = Date.now()) { lastSeen: humanizeSince(raw.lastSeen || '', now), firstSeenIso, ageHours: ageHoursFromIso(firstSeenIso, now), + ...mapTrend(raw.stats), + } +} + +// Sentry's list stats look like { auto: [[unixSeconds, count], ...] }. Keep the +// most recent buckets as a compact series for card sparklines and the overview +// chart. Every issue in one scan shares the same bucket grid, so series align +// by `trendStart` / `trendStep` and can be summed client-side. +const TREND_MAX_POINTS = 30 + +function mapTrend(stats) { + const series = stats && typeof stats === 'object' ? Object.values(stats).find(Array.isArray) : null + const points = (series || []) + .filter((p) => Array.isArray(p) && Number.isFinite(Number(p[0]))) + .slice(-TREND_MAX_POINTS) + if (points.length < 2) return { trend: [], trendStart: 0, trendStep: 0 } + return { + trend: points.map((p) => toInt(p[1])), + trendStart: Number(points[0][0]), + trendStep: Number(points[1][0]) - Number(points[0][0]), } } @@ -209,13 +229,16 @@ export function categorize({ issues = [], regressed = new Set(), escalating = ne events: issue.events, users: issue.users, url: issue.url, + trend: issue.trend || [], + trendStart: issue.trendStart || 0, + trendStep: issue.trendStep || 0, })), }) const categories = [] - if (regressions.length) categories.push(make('regressions', '🔄 Regressions', regressions)) - if (escalatingOut.length) categories.push(make('escalating', '📈 Escalating', escalatingOut)) - if (newCritical.length) categories.push(make('new-critical', '🆕 New Critical', newCritical)) + if (regressions.length) categories.push(make('regressions', 'Regressions', regressions)) + if (escalatingOut.length) categories.push(make('escalating', 'Escalating', escalatingOut)) + if (newCritical.length) categories.push(make('new-critical', 'New and critical', newCritical)) return categories } diff --git a/extensions/sentry-triage/sentryClient.mjs b/extensions/sentry-triage/sentryClient.mjs index 85939b5ca4..b596402fa9 100644 --- a/extensions/sentry-triage/sentryClient.mjs +++ b/extensions/sentry-triage/sentryClient.mjs @@ -139,25 +139,59 @@ async function getSdk() { // kicks off project discovery without awaiting it, then immediately scans issues) // AND every canvas instance in this extension process. A per-org or per-command // queue can't see the whole picture, so we funnel every SDK invocation through -// ONE FIFO chain here — nothing else in the codebase touches the SDK directly. +// ONE queue here — nothing else in the codebase touches the SDK directly. // -// `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. 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() +// `runSerial(task)` runs `task` only once the task in flight 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. 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). +// +// Waiting tasks run highest `priority` first and FIFO within a priority, so a +// user opening an issue doesn't wait behind a background scan. Every existing +// caller uses the default priority and keeps strict FIFO ordering. The returned +// promise exposes `promote(priority)` so a queued prefetch can be bumped when +// the user actually opens that issue. +export const PRIORITY = Object.freeze({ background: 0, prefetch: 1, interactive: 2 }) +const sdkQueue = [] +let sdkBusy = false + +function enqueueTask(entry) { + const at = sdkQueue.findIndex((queued) => queued.priority < entry.priority) + sdkQueue.splice(at === -1 ? sdkQueue.length : at, 0, entry) +} + +function pumpQueue() { + if (sdkBusy) return + const next = sdkQueue.shift() + if (!next) return + sdkBusy = true + next.started = true + // A failed task never wedges the queue for everyone behind it; each caller + // still awaits its own promise for the real result/error. + Promise.resolve() + .then(next.task) + .then(next.resolve, next.reject) + .finally(() => { + sdkBusy = false + pumpQueue() + }) +} -export function runSerial(task) { - // Chain onto the tail whether the previous task fulfilled or rejected, so one - // failed call never wedges the queue for everyone behind it. Each caller still - // awaits `run` for its own result/error. - const run = sdkQueue.then(task, task) - // Keep the internal chain from emitting unhandled-rejection warnings; callers - // own the real settlement via `run`. - sdkQueue = run.then(() => {}, () => {}) +export function runSerial(task, { priority = PRIORITY.background } = {}) { + let entry + const run = new Promise((resolve, reject) => { + entry = { task, priority, resolve, reject, started: false } + }) + run.promote = (nextPriority) => { + if (entry.started || !(nextPriority > entry.priority)) return + sdkQueue.splice(sdkQueue.indexOf(entry), 1) + entry.priority = nextPriority + enqueueTask(entry) + } + enqueueTask(entry) + pumpQueue() return run } @@ -332,21 +366,185 @@ export async function projectView(org, slug) { return runSerial(async () => (await getSdk()).project.view({ orgProject: `${org}/${slug}` })) } +// Issue detail for the canvas drill-in view, in two parts so the panel can paint +// progressively: +// 'core' — `issue.view` alone: header, stack trace, breadcrumbs and trace. +// 'full' — core plus the group (event stats, releases, activity) and the tag +// breakdown. The group payload already embeds activity, so there is +// no separate activities call. +// Each SDK call queues independently on runSerial and is cached briefly, with +// in-flight requests shared, so a hover prefetch, the core request and the full +// request for the same issue never repeat work. Only `issue.view` is required; +// the group and tags are best-effort enrichments. +const DETAIL_TTL_MS = 60_000 +const DETAIL_CACHE_MAX = 150 +const detailCache = new Map() + +function cachedCall(id, priority, task) { + const now = Date.now() + const hit = detailCache.get(id) + if (hit && now - hit.at < DETAIL_TTL_MS) { + hit.promise.promote(priority) + return hit.promise + } + const promise = runSerial(task, { priority }) + detailCache.delete(id) + detailCache.set(id, { at: now, promise }) + promise.catch(() => { + if (detailCache.get(id)?.promise === promise) detailCache.delete(id) + }) + while (detailCache.size > DETAIL_CACHE_MAX) detailCache.delete(detailCache.keys().next().value) + return promise +} + +export async function issueDetail(org, key, { part = 'full', priority = PRIORITY.interactive } = {}) { + const view = await cachedCall(`view:${org}:${key}`, priority, async () => (await getSdk()).issue.view({ issue: key, spans: 8 })) + if (part === 'core') return { ...shapeIssueDetail(view, null, null), partial: true } + const groupId = view && view.id + const safeOrg = encodeURIComponent(org) + const [group, tags] = groupId + ? await Promise.all([ + cachedCall(`group:${org}:${groupId}`, priority, async () => (await getSdk()).api({ endpoint: `organizations/${safeOrg}/issues/${groupId}/` })).catch(() => null), + cachedCall(`tags:${org}:${groupId}`, priority, async () => (await getSdk()).api({ + endpoint: `organizations/${safeOrg}/issues/${groupId}/tags/?key=release&key=environment&key=os&key=os_version&key=device&limit=4`, + })).catch(() => null), + ]) + : [null, null] + return shapeIssueDetail(view, group, tags) +} + +function shapeIssueDetail(view, group, tags) { + let event = view && view.event + if (typeof event === 'string') { + try { event = JSON.parse(event) } catch { event = null } + } + event = event && typeof event === 'object' ? event : {} + const entries = Array.isArray(event.entries) ? event.entries : [] + const exEntry = entries.find((e) => e && e.type === 'exception') + const exceptions = (exEntry && exEntry.data && Array.isArray(exEntry.data.values) ? exEntry.data.values : []) + .slice(0, 4) + .map((v) => ({ + type: v.type || '', + value: v.value || '', + frames: (v.stacktrace && Array.isArray(v.stacktrace.frames) ? v.stacktrace.frames : []) + .slice(-20) + .reverse() + .map((f) => ({ + file: f.filename || f.absPath || f.module || '', + line: f.lineNo ?? null, + fn: f.function || '', + inApp: !!f.inApp, + })), + })) + const crumbEntry = entries.find((e) => e && e.type === 'breadcrumbs') + const breadcrumbs = (crumbEntry && crumbEntry.data && Array.isArray(crumbEntry.data.values) ? crumbEntry.data.values : []) + .slice(-15) + .reverse() + .map((b) => ({ + ts: b.timestamp || '', + category: b.category || b.type || '', + level: b.level || '', + message: String(b.message || (b.data ? JSON.stringify(b.data) : '')).slice(0, 300), + })) + const traceCtx = (event.contexts && event.contexts.trace) || {} + const spans = (view && view.trace && Array.isArray(view.trace.spans) ? view.trace.spans : []).slice(0, 25).map((s) => ({ + id: s.span_id || '', + description: s.description || s.transaction || '', + shortId: s.short_id || '', + project: s.project_slug || '', + eventType: s.event_type || '', + level: s.level || '', + start: s.start_timestamp ?? null, + end: s.timestamp ?? s.end_timestamp ?? null, + })) + const tagList = Array.isArray(tags) ? tags : Array.isArray(group && group.tags) ? group.tags : [] + const tagBreakdown = tagList + .filter((t) => t && Array.isArray(t.topValues) && t.topValues.length) + .map((t) => { + const total = Number(t.totalValues) || t.topValues.reduce((n, v) => n + (Number(v.count) || 0), 0) || 1 + return { + key: t.key, + name: t.name || t.key, + values: t.topValues.slice(0, 4).map((v) => ({ + value: String(v.value ?? v.name ?? ''), + pct: Math.round(((Number(v.count) || 0) / total) * 100), + })), + } + }) + const stats = (group && group.stats) || {} + const toSeries = (arr) => (Array.isArray(arr) ? arr.map((p) => [Number(p[0]) || 0, Number(p[1]) || 0]) : []) + const activities = (Array.isArray(group && group.activity) ? group.activity : []) + .slice(0, 12) + .map((a) => ({ + type: a.type || '', + date: a.dateCreated || '', + user: (a.user && (a.user.name || a.user.username)) || '', + })) + const v = view || {} + return { + key: v.shortId || '', + title: v.title || '', + culprit: v.culprit || '', + type: (v.metadata && v.metadata.type) || '', + value: (v.metadata && v.metadata.value) || '', + level: v.level || '', + status: v.status || '', + substatus: v.substatus || '', + priority: v.priority || '', + platform: v.platform || event.platform || '', + project: (v.project && v.project.slug) || '', + permalink: v.permalink || '', + count: Number(v.count) || 0, + userCount: Number(v.userCount) || 0, + firstSeen: v.firstSeen || '', + lastSeen: v.lastSeen || '', + isUnhandled: !!v.isUnhandled, + firstRelease: (group && group.firstRelease && (group.firstRelease.shortVersion || group.firstRelease.version)) || '', + lastRelease: (group && group.lastRelease && (group.lastRelease.shortVersion || group.lastRelease.version)) || '', + stats: { '24h': toSeries(stats['24h']), '30d': toSeries(stats['30d']) }, + tagBreakdown, + exceptions, + breadcrumbs, + eventTags: (Array.isArray(event.tags) ? event.tags : []).slice(0, 40).map((t) => ({ key: t.key, value: String(t.value ?? '') })), + release: (event.release && (event.release.shortVersion || event.release.version)) || '', + eventDate: event.dateCreated || '', + trace: { id: traceCtx.trace_id || (v.trace && v.trace.traceId) || '', status: traceCtx.status || '', spans }, + activity: activities, + } +} + // Search issues. `orgProject` is "org/project" (or the trailing-slash "org/" form // for all projects in the org — a bare slug would be read as a project). Mirrors the previous MCP search: date sort, 100 cap, windowed by // `period`. Returns an array of raw IssueListResult objects; throws SentryError // on an API/permission failure so the caller can surface it instead of rendering // an empty (all-clear) board. +// +// Pages manually with the envelope's opaque `nextCursor`, one 100-item page per +// queued task. Asking the SDK for limit:1000 auto-pages internally inside ONE +// task (~35-50s on a large org), which held the sdkQueue and made the detail +// view wait that long; explicit pages are also far faster overall. +const ISSUE_PAGE_SIZE = 100 + export async function issueList({ orgProject, query, sort = 'date', limit = 100, period } = {}) { - return runSerial(async () => - asArray( - await (await getSdk()).issue.list({ + const out = [] + let cursor + while (out.length < limit) { + const pageLimit = Math.min(ISSUE_PAGE_SIZE, limit - out.length) + const res = await runSerial(async () => + (await getSdk()).issue.list({ ...(orgProject ? { orgProject } : {}), ...(query ? { query } : {}), ...(period ? { period } : {}), + ...(cursor ? { cursor } : {}), sort, - limit, + limit: pageLimit, }) ) - ) + const page = asArray(res) + out.push(...page) + const next = res && typeof res === 'object' && res.hasMore === true && typeof res.nextCursor === 'string' ? res.nextCursor.trim() : '' + if (!next || page.length === 0) break + cursor = next + } + return out } diff --git a/extensions/sentry-triage/server.mjs b/extensions/sentry-triage/server.mjs index 35461b8142..ac33defe0d 100644 --- a/extensions/sentry-triage/server.mjs +++ b/extensions/sentry-triage/server.mjs @@ -77,7 +77,7 @@ function hasValidToken(req, token) { return value === token } -export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onInstallDependencies, onAuthenticate, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) { +export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onInstallDependencies, onAuthenticate, onListProjects, onResolveProject, onInvalidateEnrichment, onIssueDetail, defaults } = {}) { // Per-instance state + SSE clients — never shared across canvas instances. const state = createState() state.applyRepoDefaults(defaults) @@ -329,6 +329,33 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR return } + // Issue detail for the drill-in view. + if (req.method === 'POST' && req.url === '/api/issue-detail') { + readBody(req, res).then(async (body) => { + if (body === null) return + const { key, part, prefetch } = parseJson(body) + const wanted = typeof key === 'string' ? key.trim() : '' + const options = { part: part === 'core' ? 'core' : 'full', prefetch: prefetch === true } + let status = 200 + let result + if (!wanted || !/^[A-Za-z0-9_.-]{1,80}$/.test(wanted) || !onIssueDetail || !state.getOrg()) { + status = 400 + result = { ok: false, error: 'Unknown issue.' } + } else { + try { + result = { ok: true, detail: await onIssueDetail(state.getOrg(), wanted, options) } + } catch (err) { + console.error('[sentry-triage] issue-detail failed:', err instanceof Error ? err.message : err) + status = 502 + result = { ok: false, error: err instanceof Error ? err.message : 'Could not load issue.' } + } + } + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + }) + return + } + // Set org endpoint if (req.method === 'POST' && req.url === '/api/set-org') { readBody(req, res).then((body) => { diff --git a/extensions/sentry-triage/state.mjs b/extensions/sentry-triage/state.mjs index 7eb6fcfa5b..4fb741d7ab 100644 --- a/extensions/sentry-triage/state.mjs +++ b/extensions/sentry-triage/state.mjs @@ -93,7 +93,7 @@ export function createState() { // 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 AUTO_MODEL = { id: '', label: 'Auto' } const FALLBACK_MODELS = [ { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }, { id: 'claude-opus-5', label: 'Claude Opus 5' }, diff --git a/extensions/sentry-triage/styles.mjs b/extensions/sentry-triage/styles.mjs index f310901d35..0e64d0a18f 100644 --- a/extensions/sentry-triage/styles.mjs +++ b/extensions/sentry-triage/styles.mjs @@ -1,1070 +1,1397 @@ +// Keep Sentry's Scraps palette and logo, with the crisp app-shell spacing and +// restrained controls used by Site Generator. Host theme variables keep the +// neutral surfaces legible in both light and dark themes. export function styles() { return ` + :root { + --st-font: "Rubik", var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif); + --st-mono: "Roboto Mono", var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + + --st-bg: var(--background-color-default, #18181b); + --st-fg: var(--text-color-default, #e7e7ea); + --st-muted: var(--text-color-muted, #9d9da6); + --st-border-host: var(--border-color-default, #303036); + + --st-accent: #6c5fc7; + --st-accent-hover: #6052b8; + --st-accent-fg: color-mix(in srgb, var(--st-accent) 55%, var(--st-fg)); + --st-accent-tint: color-mix(in srgb, var(--st-accent) 12%, transparent); + --st-accent-tint-strong: color-mix(in srgb, var(--st-accent) 20%, transparent); + + --st-danger: #f55459; + --st-warning: #f5b000; + --st-success: #2ba185; + --st-pink: #ff45a8; + --st-info: #3c74dd; + + --st-surface-0: var(--st-bg); + --st-surface-1: color-mix(in srgb, var(--st-bg) 97%, var(--st-fg)); + --st-surface-2: color-mix(in srgb, var(--st-bg) 94%, var(--st-fg)); + --st-surface-3: color-mix(in srgb, var(--st-bg) 89%, var(--st-fg)); + --st-border: var(--st-border-host); + --st-border-muted: color-mix(in srgb, var(--st-border) 65%, transparent); + + --st-radius-lg: 12px; + --st-radius: 8px; + --st-radius-sm: 6px; + --st-radius-pill: 999px; + --st-ease: cubic-bezier(0.23, 1, 0.32, 1); + --st-shadow-float: 0 12px 32px -8px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.2); + } + * { margin: 0; padding: 0; box-sizing: border-box; } /* The embedded webview flickers between arrow and pointer on hover for - interactive controls (links, buttons, selects, checkboxes), which is - more confusing than helpful. Force a stable arrow so nothing flashes; - hover color/underline still signal what is clickable. */ + interactive controls, which is more confusing than helpful. Keep a stable + arrow; hover color and elevation still signal what is clickable. */ a, button, select, summary, label, input[type="checkbox"], input[type="radio"] { cursor: default; } body { - font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif); - background: var(--background-color-default, #0d1117); - color: var(--text-color-default, #e6edf3); - padding: 20px; - box-sizing: border-box; + font-family: var(--st-font); + font-size: 13px; + line-height: 1.45; + background: var(--st-surface-0); + color: var(--st-fg); + --st-gutter: 24px; + padding: 0 var(--st-gutter) 32px; min-height: 100vh; display: flex; flex-direction: column; + -webkit-font-smoothing: antialiased; } - .page-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px 12px; - margin-bottom: 12px; + ::selection { background: var(--st-accent-tint-strong); } + + :focus-visible { + outline: 2px solid var(--st-accent); + outline-offset: 2px; } - .page-header h1 { - font-size: var(--text-title-large, 22px); - font-weight: var(--font-weight-semibold, 600); + ::-webkit-scrollbar { width: 10px; height: 10px; } + ::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--st-muted) 35%, transparent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + } + ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--st-muted) 55%, transparent); } + ::-webkit-scrollbar-track { background: transparent; } + + code { + font-family: var(--st-mono); + font-size: 0.92em; + background: var(--st-surface-2); + border: 1px solid var(--st-border-muted); + padding: 1px 5px; + border-radius: 5px; + } + + /* ---------- Shared controls ---------- */ + + .refresh-btn, + .project-fetch-btn, + .settings-toggle, + .work-selected-btn, + .fix-copilot-btn, + .save-settings-btn, + .gate-action-btn, + .set-default-btn, + .org-submit { + font-family: inherit; display: inline-flex; align-items: center; - gap: 8px; - line-height: 1; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--st-radius-sm); + border: 1px solid var(--st-border); + background: var(--st-surface-1); + color: var(--st-fg); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + box-shadow: none; + transition: background-color 0.15s ease, border-color 0.15s ease, + color 0.15s ease, opacity 0.15s ease; + } + .refresh-btn:hover:not(:disabled), + .settings-toggle:hover:not(:disabled), + .work-selected-btn:hover:not(:disabled), + .save-settings-btn:hover:not(:disabled), + .gate-action-btn:hover:not(:disabled), + .set-default-btn:hover:not(:disabled) { + background: var(--st-surface-2); + } + .refresh-btn:active:not(:disabled), + .project-fetch-btn:active:not(:disabled), + .settings-toggle:active:not(:disabled), + .work-selected-btn:active:not(:disabled), + .fix-copilot-btn:active:not(:disabled), + .save-settings-btn:active:not(:disabled), + .gate-action-btn:active:not(:disabled), + .set-default-btn:active:not(:disabled), + .org-submit:active:not(:disabled) { + transform: none; + box-shadow: none; } - .page-header h1 .sentry-logo { - height: 0.92em; - width: auto; - flex: 0 0 auto; - display: block; + /* Primary: Sentry's existing purple accent */ + .fix-copilot-btn, + .org-submit, + .gate-action-btn { + background: var(--st-accent); + border-color: var(--st-accent); + color: #fff; + box-shadow: none; + } + .fix-copilot-btn:hover:not(:disabled), + .org-submit:hover:not(:disabled), + .gate-action-btn:hover:not(:disabled) { + background: var(--st-accent-hover); + } + .fix-copilot-btn:active:not(:disabled), + .org-submit:active:not(:disabled), + .gate-action-btn:active:not(:disabled) { + box-shadow: none; + } + .project-fetch-btn:hover:not(:disabled) { background: var(--st-surface-2); } + .fix-copilot-btn:disabled, + .org-submit:disabled, + .gate-action-btn:disabled { + opacity: 1; + color: var(--st-muted); + background: var(--st-surface-1); + border-color: var(--st-border-muted); } - /* Sentry brand mark: matches the wordmark text color so it inverts with the - host theme (the app themes via CSS variables, not prefers-color-scheme). */ - .page-header h1 .sentry-logo path { - fill: currentColor; + .settings-toggle[aria-expanded="true"] { background: var(--st-surface-3); } + .header-controls .refresh-btn, + .header-controls .settings-toggle { + width: 30px; + height: 30px; + padding: 0; + justify-content: center; } - .refresh-btn { - display: inline-flex; - align-items: center; - gap: 5px; - background: none; - border: 1px solid var(--border-color-default, #30363d); - color: var(--text-color-default, #e6edf3); - border-radius: 6px; - padding: 4px 12px; - font-size: var(--text-small, 13px); - font-weight: var(--font-weight-semibold, 600); + button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; } - .refresh-icon { font-size: 15px; line-height: 1; } - .refresh-btn:hover { border-color: var(--color-focus-outline, #58a6ff); } - .title-mode-bar { - display: flex; + /* Inputs & selects */ + .project-select, + .period-select, + .org-input, + .settings-input, + .settings-select, + .card-model { + font-family: inherit; + height: 32px; + padding: 0 10px; + border-radius: var(--st-radius-sm); + border: 1px solid var(--st-border); + background: var(--st-surface-0); + color: var(--st-fg); + font-size: 13px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + } + .project-select:hover, + .period-select:hover, + .org-input:hover, + .settings-input:hover, + .settings-select:hover:not(:disabled), + .card-model:hover { + border-color: color-mix(in srgb, var(--st-muted) 45%, var(--st-border)); + } + .project-select:focus, + .period-select:focus, + .org-input:focus, + .settings-input:focus, + .settings-select:focus, + .card-model:focus { + outline: none; + border-color: var(--st-accent); + box-shadow: 0 0 0 3px var(--st-accent-tint-strong); + } + ::placeholder { color: color-mix(in srgb, var(--st-muted) 75%, transparent); } + + .org-select, + .period-select, + .org-switcher-select, + .settings-select, + .card-model { + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%239a8aab' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.5 6.5 8 10l3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 14px 14px; + background-color: var(--st-surface-1); + box-shadow: none; + padding-right: 28px; + } + + /* ---------- Header ---------- */ + + .page-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr); align-items: center; - gap: 12px; - margin: 4px 0 14px; - padding: 8px 12px; - border: 1px solid var(--border-color-default, #30363d); - border-radius: 8px; - background: var(--color-canvas-subtle, rgba(110, 118, 129, 0.08)); + gap: 12px 20px; + margin: 0 calc(-1 * var(--st-gutter)) 16px; + padding: 14px var(--st-gutter) 10px; + background: var(--st-surface-0); + border-bottom: 1px solid var(--st-border); } - .switch-label { + + .page-header h1 { display: inline-flex; align-items: center; - gap: 8px; - cursor: pointer; - font-size: var(--text-small, 13px); - font-weight: var(--font-weight-semibold, 600); - color: var(--text-color-default, #e6edf3); - } - .switch { position: relative; display: inline-flex; } - .switch-input { - position: absolute; - opacity: 0; - width: 36px; - height: 20px; - margin: 0; - cursor: pointer; + gap: 10px; + min-height: 30px; + min-width: 0; + line-height: 1; } - .switch-slider { - width: 36px; - height: 20px; - border-radius: 999px; - background: var(--border-color-default, #30363d); - transition: background 0.15s ease; - position: relative; + .brand-mark { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; flex: none; + border-radius: 7px; + color: var(--st-fg); + background: var(--st-surface-3); } - .switch-slider::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 50%; - background: #fff; - transition: transform 0.15s ease; - } - .switch-input:checked + .switch-slider { background: var(--color-focus-outline, #58a6ff); } - .switch-input:checked + .switch-slider::after { transform: translateX(16px); } - .switch-input:focus-visible + .switch-slider { outline: 2px solid var(--color-focus-outline, #58a6ff); outline-offset: 2px; } - .title-mode-hint { - font-size: var(--text-small, 12px); - color: var(--text-color-muted, #8b949e); + .brand-mark .sentry-logo { width: 16px; height: auto; display: block; } + .brand-mark .sentry-logo path { fill: currentColor; } + .brand-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.005em; } .header-controls { + grid-column: 2; display: flex; align-items: center; - flex-wrap: wrap; - gap: 8px 12px; - } - .control-divider { - width: 1px; - align-self: stretch; - min-height: 20px; - background: var(--border-color-default, #30363d); - } - .scope-switchers { - display: flex; - align-items: center; + justify-content: flex-end; flex-wrap: wrap; gap: 8px; } - .project-switcher { - display: flex; - align-items: center; - gap: 6px; - } - .project-switcher-label { - font-size: var(--text-small, 12px); - color: var(--text-color-muted, #8b949e); - } - .project-select { - background: var(--background-color-default, #0d1117); - border: 1px solid var(--border-color-default, #30363d); - color: var(--text-color-default, #e6edf3); - border-radius: 6px; - padding: 4px 8px; - font-size: var(--text-small, 13px); - min-width: 0; - max-width: 220px; - flex: 0 1 auto; - } - .project-select:hover { border-color: var(--color-focus-outline, #58a6ff); } - .org-switcher-select { cursor: pointer; } - .period-switcher { - display: flex; - align-items: center; - gap: 6px; - } + .header-controls > .control-divider { + width: 1px; + height: 18px; + margin: 0 4px; + background: var(--st-border-muted); + } + .header-controls > .control-divider:last-of-type { display: none; } + .header-controls > #refresh { position: static; margin-left: auto; } + .header-controls > #refresh + #toggle-pr-settings { margin-left: -4px; } + .scope-switchers { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } + .project-switcher, + .period-switcher { display: flex; align-items: center; gap: 6px; } + .project-switcher-label, .period-switcher-label { - font-size: var(--text-small, 12px); - color: var(--text-color-muted, #8b949e); + font-size: 12px; + color: var(--st-muted); } - .period-select { - background: var(--background-color-default, #0d1117); - border: 1px solid var(--border-color-default, #30363d); - color: var(--text-color-default, #e6edf3); - border-radius: 6px; - padding: 4px 8px; - font-size: var(--text-small, 13px); - cursor: pointer; + .header-controls .project-switcher-label, + .header-controls .period-switcher-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } - .period-select:hover { border-color: var(--color-focus-outline, #58a6ff); } - .project-fetch-btn { - background: var(--color-accent-emphasis, #1f6feb); - border: 1px solid var(--color-accent-emphasis, #1f6feb); - color: var(--color-fg-on-emphasis, #ffffff); - border-radius: 6px; - padding: 4px 12px; - font-size: var(--text-small, 13px); - font-weight: var(--font-weight-semibold, 600); - cursor: pointer; - transition: opacity 0.15s; + .header-controls .project-select, + .header-controls .period-select { height: 30px; } + .header-controls .project-select { width: 150px; } + .header-controls #org-switcher { width: 120px; } + .header-controls .project-fetch-btn { height: 30px; padding: 0 10px; } + .refresh-icon { flex: none; } + + #triage-desc { display: none; } + .page-description { + font-size: 13px; + color: var(--st-muted); + margin: 0 0 2px; } - .project-fetch-btn:hover { opacity: 0.85; } + .page-description strong { color: var(--st-fg); font-weight: 500; } + + /* ---------- Project autocomplete ---------- */ - /* Project autocomplete (a text input + a filtered suggestion menu). A native - does not repaint in this webview, and a chrome so dropdowns match the height and - padding of sibling text inputs, then draw our own caret. */ - .org-select, - select.settings-input, - .settings-select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%238b949e' stroke-width='1.5'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px 12px; - } - .org-select { padding-right: 30px; } - select.settings-input, - .settings-select { padding-right: 26px; } - .save-settings-btn { - margin-top: 8px; - border: 1px solid var(--color-focus-outline, #58a6ff); - background: none; - color: var(--color-focus-outline, #58a6ff); - border-radius: 6px; + .dv-ex + .dv-ex { margin-top: 14px; } + .dv-ex-head { margin-bottom: 8px; font-family: var(--st-mono); font-size: 12.5px; overflow-wrap: anywhere; } + .dv-frames { list-style: none; border: 1px solid var(--st-border-muted); border-radius: var(--st-radius-sm); overflow: hidden; } + .dv-frame { + display: flex; + align-items: baseline; + gap: 10px; padding: 6px 10px; + font-family: var(--st-mono); font-size: 12px; - font-weight: var(--font-weight-semibold, 600); - transition: background 0.15s; - } - .save-settings-btn:hover:not(:disabled) { - background: rgba(88, 166, 255, 0.12); + border-bottom: 1px solid var(--st-border-muted); } + .dv-frame:last-child { border-bottom: none; } + .dv-frame.in-app { background: var(--st-surface-1); } + .dv-fn { font-weight: 500; } + .dv-file { flex: 1; min-width: 0; color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - /* ---- Connection preflight: Sentry setup gate + GitHub warning banner ---- */ - .sentry-gate { display: none; } - body.sentry-gated .sentry-gate { display: block; } - /* When Sentry isn't connected, hide the normal chrome and show only the gate. */ - body.sentry-gated .header-controls, - body.sentry-gated .org-picker, - body.sentry-gated .setup-instructions, - body.sentry-gated .setup-scan, - body.sentry-gated #work-settings, - body.sentry-gated .page-description, - body.sentry-gated .page-subtitle, - body.sentry-gated #work-toolbar, - body.sentry-gated #categories, - body.sentry-gated #empty-state { display: none !important; } + .dv-trace-id { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 12px; color: var(--st-muted); } + .dv-trace-id code { + padding: 1px 6px; + border-radius: 4px; + font-family: var(--st-mono); + font-size: 12px; + color: var(--st-fg); + background: var(--st-surface-3); + overflow-wrap: anywhere; + } + .dv-spans { list-style: none; margin-left: 4px; border-left: 2px solid var(--st-border-muted); } + .dv-span { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding: 6px 10px; } + .dv-span.current { background: var(--st-surface-2); border-radius: 0 6px 6px 0; } + .dv-span-dot { grid-row: span 2; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--st-muted); } + .dv-span-dot.error { background: var(--st-danger); } + .dv-span-dot.transaction { background: var(--st-info); } + .dv-span-desc { font-size: 12.5px; overflow-wrap: anywhere; } + .dv-span-meta { grid-column: 2; font-size: 11.5px; color: var(--st-muted); } - .sentry-gate { - margin: 8px 0 4px; - } - .gate-card { - border: 1px solid var(--border-color-attention, rgba(210, 153, 34, 0.4)); - border-left: 3px solid var(--text-color-attention, #d29922); - border-radius: 8px; - background: var(--background-color-attention, rgba(210, 153, 34, 0.1)); - padding: 16px 18px; - max-width: 560px; - } - .gate-icon { font-size: 22px; line-height: 1; } - .gate-card h2 { - font-size: 16px; - font-weight: var(--font-weight-semibold, 600); - margin: 8px 0 6px; - } - .gate-lead { - font-size: 13px; - line-height: 1.5; - color: var(--text-color-default, #e6edf3); - margin: 0 0 8px; - } - .gate-steps { - margin: 0; - font-size: 12.5px; - line-height: 1.6; - color: var(--text-color-muted, #8b949e); + .dv-crumbs { list-style: none; } + .dv-crumbs li { + display: grid; + grid-template-columns: 110px minmax(0, 1fr) auto; + gap: 10px; + padding: 5px 0; + font-size: 12px; + border-bottom: 1px solid var(--st-border-muted); } - .gate-steps code { - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: 11.5px; - background: rgba(110, 118, 129, 0.15); - padding: 1px 5px; - border-radius: 4px; + .dv-crumbs li:last-child { border-bottom: none; } + .dv-crumb-cat { font-family: var(--st-mono); color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dv-crumb-msg { font-family: var(--st-mono); overflow-wrap: anywhere; } + .dv-crumb-time { color: var(--st-muted); font-variant-numeric: tabular-nums; } + + .dv-activity { list-style: none; } + .dv-activity li { position: relative; display: flex; align-items: baseline; gap: 10px; padding: 5px 0 5px 20px; font-size: 12.5px; } + .dv-activity li::before { + content: ""; + position: absolute; + z-index: 1; + left: 1px; + top: 11px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--st-border); + box-shadow: 0 0 0 3px var(--st-bg); } - .gate-error { - margin: 10px 0 0; - font-size: 12px; - line-height: 1.5; - color: var(--danger-color, #f85149); - background: rgba(248, 81, 73, 0.1); - border: 1px solid rgba(248, 81, 73, 0.35); - border-radius: 6px; - padding: 8px 10px; - text-align: left; + .dv-activity li:first-child::before { background: var(--st-fg); } + .dv-activity li:not(:last-child)::after { + content: ""; + position: absolute; + left: 4px; + top: 14px; + bottom: -14px; + width: 1px; + background: var(--st-border-muted); } - .gate-action-btn { - margin-top: 4px; - padding: 6px 14px; - font-size: 12.5px; - font-weight: var(--font-weight-semibold, 600); - color: var(--text-color-default, #e6edf3); - background: var(--background-color-default, #21262d); - border: 1px solid var(--border-color-default, #30363d); - border-radius: 6px; - cursor: pointer; + .dv-act-what { flex: 1; } + .dv-act-when { font-size: 12px; color: var(--st-muted); } + + .dv-kv { display: grid; grid-template-columns: minmax(120px, max-content) minmax(0, 1fr); gap: 4px 16px; font-size: 12px; } + .dv-kv dt { font-family: var(--st-mono); color: var(--st-muted); } + .dv-kv dd { font-family: var(--st-mono); overflow-wrap: anywhere; } + + .dv-error { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 12px; text-align: center; } + .dv-error h2 { font-size: 16px; font-weight: 600; } + .dv-error .detail-btn { margin-top: 6px; } + + .sk { display: block; border-radius: 6px; background: var(--st-surface-3); animation: sk-pulse 1.2s ease-in-out infinite; } + .sk-line { height: 12px; } + .sk-badge { width: 64px; height: 18px; border-radius: var(--st-radius-pill); } + .sk-num { width: 56px; height: 16px; margin: 3px 0 2px; } + .sk-culprit { width: 45%; margin-top: 4px; } + .sk-total { width: 38%; margin-bottom: 10px; } + .sk-chart { height: 118px; } + .sk-frames { height: 168px; } + .dv-sk-lines { display: flex; flex-direction: column; gap: 12px; padding-top: 4px; } + @keyframes sk-pulse { 50% { opacity: 0.5; } } + @media (prefers-reduced-motion: reduce) { .sk { animation: none; } } + + /* ---------- Responsive ---------- */ + + @media (max-width: 900px) { + .page-header { grid-template-columns: minmax(0, 1fr); } + .header-controls { + grid-column: 1; + justify-content: flex-start; + } + .dv-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dv-stat:nth-child(3) { border-left: none; } + .dv-stat:nth-child(n + 3) { border-top: 1px solid var(--st-border-muted); } } - .gate-action-btn:hover:not(:disabled) { border-color: var(--color-focus-outline, #58a6ff); } - .gate-action-btn:disabled { opacity: 0.6; cursor: default; } - .gate-install-status { - margin: 8px 0 0; - font-size: 12px; - line-height: 1.5; - color: var(--text-color-muted, #8b949e); + @media (max-width: 640px) { + body { --st-gutter: 16px; padding: 0 var(--st-gutter) 16px; } + .toolbar-model { margin-left: 0; } + .toolbar-model .settings-select { min-width: 0; max-width: 104px; } + .card-meta { gap: 12px; } + .detail-body { padding: 16px; } + .dv-head { padding-bottom: 16px; } + .detail-crumb { display: none; } + .dv-crumbs li { grid-template-columns: minmax(0, 1fr) auto; } + .dv-crumb-cat { display: none; } + .settings-row { grid-template-columns: 1fr; } + } + @media (max-width: 520px) { + .ov-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ov-grid .ov-tile:first-child { grid-column: 1 / -1; } + .ov-grid .ov-tile:nth-child(2) { border-left: none; } + .ov-grid .ov-tile:nth-child(n + 2) { border-top: 1px solid var(--st-border-muted); } } - .toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: var(--border-color-default, #1f2937); - border: 1px solid var(--border-color-default, #30363d); - color: var(--text-color-default, #e6edf3); - padding: 10px 20px; - border-radius: 8px; - font-size: 13px; - z-index: 1000; + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } ` } diff --git a/plugins/sentry-triage/README.md b/plugins/sentry-triage/README.md index 2a4cc35dbf..397587d4cc 100644 --- a/plugins/sentry-triage/README.md +++ b/plugins/sentry-triage/README.md @@ -9,6 +9,13 @@ issues off for tracking or a fix PR. copilot plugin install sentry-triage@awesome-copilot ``` +## What's new in 1.3.0 + +- **Faster issue detail.** Issue lists are fetched as 100-item pages, each queued as its own SDK call, so opening an issue mid-scan no longer waits behind one long 1000-item request. +- **Issue drill-in.** A detail view loads issue data on demand, with lower-priority prefetching. +- **Roomier layout.** More spacing between categories, headers, and cards, with larger titles and summaries. +- **Trend charts.** Each card shows a 30-point event sparkline, and the overview has an "Events over time" chart. Both use the `stats` data Sentry already returns, so they add no API calls. + ## Source This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). diff --git a/plugins/sentry-triage/plugin.json b/plugins/sentry-triage/plugin.json index 4c3647536f..6ad821fba2 100644 --- a/plugins/sentry-triage/plugin.json +++ b/plugins/sentry-triage/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sentry-triage", "description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.", - "version": "1.2.0", + "version": "1.3.0", "author": { "name": "Liz Tom", "url": "https://github.com/liztom"