From a63cb8c6e2877a5ab8ef5a0195b85309619f54d1 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 09:43:10 -0700 Subject: [PATCH 01/19] feat(seer): Rework the autofix overview around state-driven sections Squash of feat/autofix-overview-state-sections. Rebuild the Seer autofix overview cards around a single narrative flow (progress ring, proposed fix / diagnosis body, next steps, inline file differ, deep-link focus mode, project selector, table view toggle, Investigate fallback CTA) and drive the overview sections from issue.autofix_state activity data. --- .../autofixIssuesDemo/useAutofixIssues.tsx | 19 +- .../seerWorkflows/overview/attentionBadge.tsx | 42 +- .../overview/buildOverviewRows.ts | 80 +- .../seerWorkflows/overview/index.spec.tsx | 688 ++++++++++++------ .../views/seerWorkflows/overview/index.tsx | 633 +++++++--------- .../seerWorkflows/overview/issueCard.tsx | 540 ++++++++------ .../seerWorkflows/overview/runQuestions.tsx | 56 +- .../overview/sectionIssueCard.tsx | 107 +++ .../seerWorkflows/overview/statusGroups.tsx | 117 +++ .../app/views/seerWorkflows/overview/types.ts | 11 +- .../overview/useAutofixSections.tsx | 103 +++ 11 files changed, 1439 insertions(+), 957 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/sectionIssueCard.tsx create mode 100644 static/app/views/seerWorkflows/overview/statusGroups.tsx create mode 100644 static/app/views/seerWorkflows/overview/useAutofixSections.tsx diff --git a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx index d4f958346898..7260ebb31b40 100644 --- a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx +++ b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx @@ -136,6 +136,16 @@ export interface AutofixIssue extends Issue { interface UseAutofixIssuesParams { cursor?: string; + // Gates the issues request; pass page-filters readiness so the initial + // fetch waits for the restored project selection. Defaults to true. + enabled?: boolean; + // Fetch exactly these group ids instead of searching the stream. The + // endpoint ignores every other query component in this mode, so a + // deep-linked issue resolves even outside the list's filters/pagination. + groupIds?: string[]; + // Project ids to scope the issue stream to (page-filters selection: [] is + // "My Projects", [-1] is all). Defaults to all accessible projects. + projects?: number[]; query?: string; // One-shot questions asked about each run (repeatable `question` param, // capped at 5 by the endpoint). Defaults to this page's demo set. @@ -161,6 +171,9 @@ interface UseAutofixIssuesResult { export function useAutofixIssues({ query, cursor, + enabled = true, + groupIds: pinnedGroupIds, + projects, questions = DEMO_QUESTIONS, runsQuery: runsQueryFilter = RUNS_QUERY, }: UseAutofixIssuesParams): UseAutofixIssuesResult { @@ -173,7 +186,10 @@ export function useAutofixIssues({ query: { query: withRequiredFilter(query ?? ''), cursor, - project: -1, + group: pinnedGroupIds, + // In group-id mode the page-filters project selection must not hide + // the deep-linked issue — the backend still enforces access. + project: pinnedGroupIds ? -1 : (projects ?? -1), statsPeriod: '90d', // Explicit endpoint default: last-seen desc selects the issues still // actively occurring as the candidate pool; callers order the loaded @@ -183,6 +199,7 @@ export function useAutofixIssues({ }, staleTime: 30_000, }), + enabled, select: selectJsonWithHeaders, }); diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx index 3d318f8569cb..5591e9b4582c 100644 --- a/static/app/views/seerWorkflows/overview/attentionBadge.tsx +++ b/static/app/views/seerWorkflows/overview/attentionBadge.tsx @@ -93,40 +93,6 @@ export function getAttentionReason(row: OverviewRow): AttentionReason | null { return null; } -// Actionable tiers ordered by urgency-to-a-human: blocked-on-you first, then -// nearest-to-shipping, with the low-confidence retry last. -const ATTENTION_TRIAGE_RANK: Record = { - awaiting_input: 0, - review_pr: 1, - code_changes_ready: 2, - solution_ready: 3, - errored: 4, -}; - -/** - * Queue position for the overview's triage sort: everything a human can act on - * (ranked by urgency), then rows whose state is still loading (parked in the - * middle so they don't leap from the top when they resolve), then Seer-is- - * working, then diagnosed-only, with merged wins sinking to the bottom as an - * archive shelf. Lower sorts first. - */ -export function getTriageRank(row: OverviewRow, attention: AttentionReason | null) { - if (attention !== null) { - return ATTENTION_TRIAGE_RANK[attention]; - } - if (row.statePending) { - return 5; - } - if (row.isProcessing) { - return 6; - } - if (row.prMerged) { - return 8; - } - // Diagnosed-only: informational, optional next step. - return 7; -} - const AccentLinkButton = styled(LinkButton)` background: ${p => p.theme.tokens.background.accent}; border-color: ${p => p.theme.tokens.border.accent}; @@ -163,7 +129,7 @@ export function AttentionBadge({ if (reason === 'code_changes_ready') { return ( - } to={to}> + } to={to}> {meta.label} @@ -172,7 +138,7 @@ export function AttentionBadge({ if (reason === 'solution_ready') { return ( - } to={to}> + } to={to}> {meta.label} @@ -181,7 +147,7 @@ export function AttentionBadge({ if (reason === 'errored') { return ( - } to={to}> + } to={to}> {meta.label} @@ -190,7 +156,7 @@ export function AttentionBadge({ return ( - } to={to}> + } to={to}> {meta.label} diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index b421520fd6aa..5a90ed496855 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -5,10 +5,7 @@ import { isCodeChangesArtifact, isCodeChangesSection, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import type { - AutofixIssue, - RunQuestion, -} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; +import type {RunQuestion} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; import {RUN_QUESTIONS} from './runQuestions'; import {mapRunSourceToTrigger} from './triggerBadge'; @@ -19,6 +16,7 @@ import type { PatchStats, RunAnalysisEntry, } from './types'; +import type {OverviewIssue} from './useAutofixSections'; const OUTCOME_ORDER: AutofixOutcome[] = [ 'root_cause', @@ -69,14 +67,24 @@ function deriveRunStatus(state: ExplorerAutofixState | null): AutofixRunStatus { } } -function extractPatchStats(state: ExplorerAutofixState | null): PatchStats | undefined { +// A diff qualifies for the on-card differ only when it is genuinely small: +// few files, few changed lines, and bounded hunk context so a fix with huge +// surrounding context can't blow the card up. +const INLINE_DIFF_MAX_FILES = 2; +const INLINE_DIFF_MAX_CHANGED_LINES = 25; +const INLINE_DIFF_MAX_RENDERED_LINES = 60; + +function extractPatchInfo(state: ExplorerAutofixState | null): { + inlinePatches?: OverviewRow['inlinePatches']; + patchStats?: PatchStats; +} { const section = getOrderedAutofixSections(state).find(isCodeChangesSection); if (!section) { - return undefined; + return {}; } const artifact = getAutofixArtifactFromSection(section); if (!isCodeChangesArtifact(artifact)) { - return undefined; + return {}; } // Disambiguate paths with the repo name only when the diff spans repos. const multiRepo = new Set(artifact.map(filePatch => filePatch.repo_name)).size > 1; @@ -90,11 +98,26 @@ function extractPatchStats(state: ExplorerAutofixState | null): PatchStats | und })) // Most-changed files first, so a capped tooltip shows what matters. .sort((a, b) => b.added + b.removed - (a.added + a.removed)); + const added = artifact.reduce((sum, filePatch) => sum + filePatch.patch.added, 0); + const removed = artifact.reduce((sum, filePatch) => sum + filePatch.patch.removed, 0); + const renderedLines = artifact.reduce( + (sum, filePatch) => + sum + filePatch.patch.hunks.reduce((lines, hunk) => lines + hunk.lines.length, 0), + 0 + ); + const inlineEligible = + artifact.length <= INLINE_DIFF_MAX_FILES && + added + removed <= INLINE_DIFF_MAX_CHANGED_LINES && + renderedLines > 0 && + renderedLines <= INLINE_DIFF_MAX_RENDERED_LINES; return { - fileList, - files: artifact.length, - added: artifact.reduce((sum, filePatch) => sum + filePatch.patch.added, 0), - removed: artifact.reduce((sum, filePatch) => sum + filePatch.patch.removed, 0), + patchStats: {fileList, files: artifact.length, added, removed}, + inlinePatches: inlineEligible + ? artifact.map(filePatch => ({ + patch: filePatch.patch, + repoName: multiRepo ? filePatch.repo_name : undefined, + })) + : undefined, }; } @@ -201,17 +224,27 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { entries.push({ key: config.key, label: config.label, - placement: config.placement, answer, }); }); return {entries, headline}; } -function buildOverviewRow(issue: AutofixIssue): OverviewRow { - const state = issue.autofixState; +export interface OverviewRunData { + lastTriggeredAt?: string; + outputs?: RunQuestion[]; + pullRequests?: Array<{status: string | null}>; + source?: string | null; +} + +export function buildOverviewRow( + issue: OverviewIssue, + run: OverviewRunData | null, + state: ExplorerAutofixState | null, + statePending: boolean +): OverviewRow { const eventCount = Number(issue.count); - const {entries: analysis, headline} = buildAnalysis(issue.run?.outputs); + const {entries: analysis, headline} = buildAnalysis(run?.outputs); return { headline, @@ -223,26 +256,21 @@ function buildOverviewRow(issue: AutofixIssue): OverviewRow { eventCount: Number.isFinite(eventCount) ? eventCount : 0, userCount: issue.userCount, lastSeen: issue.lastSeen, - fixabilityScore: issue.seerFixabilityScore, lastActivityAt: state?.updated_at ?? - issue.run?.lastTriggeredAt ?? + run?.lastTriggeredAt ?? issue.seerAutofixLastTriggered ?? issue.lastSeen, autofixRunStatus: deriveRunStatus(state), - prMerged: (issue.run?.pullRequests ?? []).some(pr => pr.status === 'merged'), + prMerged: (run?.pullRequests ?? []).some(pr => pr.status === 'merged'), isProcessing: state?.status === 'processing', - statePending: issue.autofixPhasePending, + statePending, outcomes: deriveAutofixOutcomes(state), - trigger: mapRunSourceToTrigger(issue.run?.source ?? null), - rawSource: issue.run?.source ?? null, + trigger: mapRunSourceToTrigger(run?.source ?? null), + rawSource: run?.source ?? null, analysis, - patchStats: extractPatchStats(state), + ...extractPatchInfo(state), pendingQuestion: extractPendingQuestion(state), ...extractPr(state), }; } - -export function buildOverviewRows(issues: AutofixIssue[]): OverviewRow[] { - return issues.map(buildOverviewRow); -} diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 82d47892743b..4dff75ae0f67 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -1,8 +1,12 @@ import {GroupFixture} from 'sentry-fixture/group'; import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; +import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; import AutofixOverview from 'sentry/views/seerWorkflows/overview'; import {RUN_QUESTIONS} from 'sentry/views/seerWorkflows/overview/runQuestions'; @@ -12,13 +16,24 @@ describe('AutofixOverview', () => { }); const basePath = `/organizations/${organization.slug}/issues/autofix/overview/`; + // The five status buckets the page always renders, in fixed order. Each is + // one GET /issues/ differentiated only by its `query` filter, so section + // mocks are matched on that string. + const SECTION_QUERIES = { + review_pr: 'has:issue.seer_last_run issue.autofix_state:review_pr', + code_changes_ready: 'has:issue.seer_last_run issue.autofix_state:code_changes_ready', + solution_ready: 'has:issue.seer_last_run issue.autofix_state:solution_ready', + needs_investigation: + 'has:issue.seer_last_run issue.autofix_state:needs_investigation', + merged: 'has:issue.seer_last_run issue.autofix_state:merged', + }; + const issue = GroupFixture({ id: '2', shortId: 'PROJ-1', title: 'TypeError in checkout cart', count: '100', userCount: 5, - seerFixabilityScore: 0.75, }); // A run that reached every stage and opened a PR. @@ -85,51 +100,99 @@ describe('AutofixOverview', () => { }, }; + // The per-card runs payload: a night_shift-triggered run whose one-shot + // answers become the card's Root cause / Proposed fix prose. + const defaultRun = { + id: 'run-1', + type: 'explorer', + groupId: '2', + source: 'night_shift', + lastTriggeredAt: '2026-07-14T09:00:00Z', + dateCreated: '2026-07-14T09:00:00Z', + outputs: [ + { + key: 'user_0', + question: RUN_QUESTIONS[0]!.prompt, + answer: + 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.', + }, + { + key: 'user_1', + question: RUN_QUESTIONS[1]!.prompt, + answer: 'Restores the Authorization header as a fallback.', + }, + ], + }; + + // The repo-wide IntersectionObserver mock (tests/js/setup.ts) is a no-op + // whose observe() never fires its callback, so LazyRender content would + // stay hidden forever. Report every observed node as immediately + // intersecting so cards hydrate the way they would once scrolled into view. + const OriginalIntersectionObserver = window.IntersectionObserver; + beforeAll(() => { + window.IntersectionObserver = class MockIntersectionObserver { + root = null; + rootMargin = ''; + scrollMargin = ''; + thresholds = []; + takeRecords = jest.fn(); + private readonly callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + this.callback( + [{target, isIntersecting: true} as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ); + } + unobserve() {} + disconnect() {} + } as unknown as typeof IntersectionObserver; + }); + afterAll(() => { + window.IntersectionObserver = OriginalIntersectionObserver; + }); + + // Register one section's issue mock, matched on its query bucket. `hits` + // seeds the X-Hits header that drives the section count badge. + function mockSection(query: string, options: {body?: unknown[]; hits?: string} = {}) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({query})], + body: options.body ?? [], + ...(options.hits === undefined ? {} : {headers: {'X-Hits': options.hits}}), + }); + } + beforeEach(() => { MockApiClient.clearMockResponses(); - + // Collapsed status groups persist to localStorage; keep tests isolated. + localStorage.clear(); + + // The project page filter needs seeded page-filter + project stores, or + // PageFiltersContainer never reports ready and the section queries stay + // gated off. + PageFiltersStore.onInitializeUrlState(PageFiltersFixture()); + ProjectsStore.loadInitialData([ProjectFixture()]); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/`, - body: [issue], + url: `/organizations/${organization.slug}/projects/`, + body: [ProjectFixture()], }); + + // One issue lives in the review bucket (X-Hits 3 exceeds the returned + // body, proving the count comes from the header); the other four are empty. + mockSection(SECTION_QUERIES.review_pr, {body: [issue], hits: '3'}); + mockSection(SECTION_QUERIES.code_changes_ready); + mockSection(SECTION_QUERIES.solution_ready); + mockSection(SECTION_QUERIES.needs_investigation); + mockSection(SECTION_QUERIES.merged); + + // Per-card content: the IntersectionObserver override above reports every + // card as in view, so these fire once per rendered card. MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'night_shift', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_0', - question: RUN_QUESTIONS[0]!.prompt, - answer: - 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.', - }, - { - key: 'user_1', - question: RUN_QUESTIONS[1]!.prompt, - answer: - 'JWT viewer auth landed before the proxy supported it, so requests fail; the run opened a PR restoring the header.', - }, - { - key: 'user_2', - question: RUN_QUESTIONS[2]!.prompt, - answer: 'Restores the Authorization header as a fallback.', - }, - { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - // Inline "•" bullets — the normalizer must split them into a list. - answer: - '• Confirm the fallback header does not leak the key. • Verify the proxy accepts both headers.', - }, - ], - }, - ], + body: [defaultRun], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, @@ -154,6 +217,58 @@ describe('AutofixOverview', () => { expect(screen.queryByText('Autofix Overview')).not.toBeInTheDocument(); }); + it('renders the five status sections with server-provided counts', async () => { + renderPage(); + + // Every section renders, in order, with its X-Hits count in the badge. + const reviewHeader = await screen.findByRole('button', { + name: 'Awaiting your review 3', + }); + expect( + screen.getByRole('button', {name: 'Code changes ready 0'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Ready to generate code 0'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Needs investigation 0'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Merged 0'})).toBeInTheDocument(); + + // The review bucket's issue renders as a card between its own header and + // the next section — server-bucketed, not classified client-side. + const titleLink = await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }); + const codeHeader = screen.getByRole('button', {name: 'Code changes ready 0'}); + expect( + reviewHeader.compareDocumentPosition(titleLink) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + titleLink.compareDocumentPosition(codeHeader) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + // Card prose is hydrated from the per-card runs fetch. + expect( + screen.getByText('Commit c5bb895 stopped sending the Authorization header.') + ).toBeInTheDocument(); + + // The four empty buckets each show their own empty text. + expect(screen.getAllByText('No issues')).toHaveLength(4); + }); + + it('drops the outcome / needs-attention filters and pagination', async () => { + renderPage(); + + await screen.findByRole('button', {name: 'Awaiting your review 3'}); + + expect(screen.queryByRole('button', {name: /Outcome/})).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: /Needs attention/}) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument(); + }); + it('renders a card with real run metadata', async () => { renderPage(); @@ -180,6 +295,11 @@ describe('AutofixOverview', () => { expect(screen.getByText('+42')).toBeInTheDocument(); expect(screen.getByText('−7')).toBeInTheDocument(); + // This diff fails the inline-differ gates twice over (49 changed lines + // is past the cap, and its hunks are empty), so the file path lives only + // in the pill's hover tooltip — no diff header on the card. + expect(screen.queryByText('src/cart.py')).not.toBeInTheDocument(); + // Hovering the diff pill lists the changed files. await userEvent.hover(screen.getByText('1 file')); expect(await screen.findByText('src/cart.py')).toBeInTheDocument(); @@ -188,52 +308,57 @@ describe('AutofixOverview', () => { expect(screen.getByText(/100 events/)).toBeInTheDocument(); }); - it('shows a single body block and collapses the full analysis', async () => { + it('switches between card and table views', async () => { renderPage(); - // The body is either/or: code was drafted, so the proposed-fix block - // renders and the summary does not (it would describe the same change). - expect(await screen.findByText('Proposed fix')).toBeVisible(); - expect( - screen.getByText('Restores the Authorization header as a fallback.') - ).toBeVisible(); expect( - screen.queryByText( - 'JWT viewer auth landed before the proxy supported it, so requests fail; the run opened a PR restoring the header.' - ) - ).not.toBeInTheDocument(); + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + expect(screen.getByText('Root cause')).toBeVisible(); + expect(screen.getByRole('radio', {name: 'Card view'})).toBeChecked(); - // The timestamp is labeled as run activity. - expect(screen.getByText(/^updated/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('radio', {name: 'Table view'})); - // Root cause, notes, and the short id stay behind the disclosure. - const disclosure = screen.getByRole('button', {name: 'Full analysis'}); - expect( - screen.getByText('Commit c5bb895 stopped sending the Authorization header.') - ).not.toBeVisible(); - expect(screen.getByText('PROJ-1')).not.toBeVisible(); + expect(screen.getByRole('radio', {name: 'Table view'})).toBeChecked(); + // The full analysis is card-only; the table row is the scannable summary. + expect(screen.queryByText('Root cause')).not.toBeInTheDocument(); + expect(screen.getByText('PROJ-1')).toBeVisible(); + expect(screen.getByText(/100 events · 5 users/)).toBeVisible(); + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + 'https://github.com/getsentry/sentry/pull/123' + ); + }); - await userEvent.click(disclosure); + it('renders the analysis on the card face in thought order', async () => { + renderPage(); - expect(screen.getByText('PROJ-1')).toBeVisible(); - // Section headings are the clean labels, never the raw prompt text. - expect(screen.getByText('Root cause')).toBeVisible(); + // Both sections render with no expansion needed… + const rootCause = await screen.findByText('Root cause'); + const proposedFix = screen.getByText('Proposed fix'); expect( screen.getByText('Commit c5bb895 stopped sending the Authorization header.') ).toBeVisible(); - // Code was drafted, so the notes section is a review checklist, with the - // inline-bullet answer normalized into separate list items. - expect(screen.getByText('Review checklist')).toBeVisible(); expect( - screen.getByText('Confirm the fallback header does not leak the key.') + screen.getByText('Restores the Authorization header as a fallback.') ).toBeVisible(); - expect(screen.getByText('Verify the proxy accepts both headers.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); - // Fixability lives in the expanded state as a bucketed tag (0.75 > 0.7). - expect(screen.getByText('High fixability')).toBeVisible(); + + // …in thought order: what broke, then what changed. + expect( + rootCause.compareDocumentPosition(proposedFix) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + // The timestamp is labeled as run activity. + expect(screen.getByText(/^updated/)).toBeInTheDocument(); + + // Identity sits in the tail: short id + exactly one level marker. + expect(screen.getByText('PROJ-1')).toBeVisible(); + expect(screen.getAllByText('Level: Warning')).toHaveLength(1); }); - it('shows Diagnosis and Next steps when no code was drafted', async () => { + it('leads with the root cause and a single next step when no code was drafted', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ @@ -246,14 +371,9 @@ describe('AutofixOverview', () => { dateCreated: '2026-07-14T09:00:00Z', outputs: [ { - key: 'user_1', - question: RUN_QUESTIONS[1]!.prompt, - answer: 'A mechanism sentence without any drafted fix.', - }, - { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - answer: '- Decide whether Seer should generate a fix.', + key: 'user_2', + question: RUN_QUESTIONS[2]!.prompt, + answer: 'Decide whether to relax the constraint.', }, ], }, @@ -267,78 +387,14 @@ describe('AutofixOverview', () => { await screen.findByRole('link', {name: 'TypeError in checkout cart'}) ).toBeInTheDocument(); - // No drafted fix → the body block is the Diagnosis variant. - expect(screen.getByText('Diagnosis')).toBeVisible(); - expect( - screen.getByText('A mechanism sentence without any drafted fix.') - ).toBeVisible(); + // The notes read as the next step (waits for the per-card runs fetch). + expect(await screen.findByText('Next steps')).toBeVisible(); + expect(screen.getByText('Decide whether to relax the constraint.')).toBeVisible(); + // No drafted fix → no fix section. expect(screen.queryByText('Proposed fix')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', {name: 'Full analysis'})); - - // …and the notes section is Next steps rather than a review checklist. - expect(screen.getByText('Next steps')).toBeVisible(); - expect(screen.getByText('Decide whether Seer should generate a fix.')).toBeVisible(); - expect(screen.queryByText('Review checklist')).not.toBeInTheDocument(); }); - it('toggles quick filters from the stat cards via the URL', async () => { - const {router} = renderPage(); - - // Wait for the card to load so the stat counts reflect the row. - expect(await screen.findByRole('button', {name: 'Review PR'})).toBeInTheDocument(); - - // The PR-opened row counts toward "Awaiting your review". - const statCard = screen.getByRole('button', { - name: /Awaiting your review/, - }); - expect(statCard).toHaveTextContent('1'); - - await userEvent.click(statCard); - expect(router.location.query.quick).toBe('review_pr'); - - await userEvent.click(statCard); - expect(router.location.query.quick).toBeUndefined(); - }); - - it('applies the outcome filter with AND semantics', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: { - autofix: { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, - ], - }, - }, - }); - - renderPage(); - - const title = 'Proxy requests fail without Authorization header'; - expect(await screen.findByRole('link', {name: title})).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', {name: /Outcome/})); - - await userEvent.click(screen.getByRole('option', {name: 'Root cause'})); - expect(await screen.findByRole('link', {name: title})).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('option', {name: 'Code changes'})); - expect(await screen.findByText('No issues match your filters.')).toBeInTheDocument(); - }); - - it('falls back to a View run action when nothing needs attention', async () => { + it('falls back to an Investigate action when nothing needs attention', async () => { // A run that only found a root cause: no attention reason, but the card // should still offer a way into the run (Seer drawer deep link). MockApiClient.addMockResponse({ @@ -365,14 +421,14 @@ describe('AutofixOverview', () => { renderPage(); - const viewRun = await screen.findByRole('button', {name: 'View run'}); - expect(viewRun).toHaveAttribute( + const investigate = await screen.findByRole('button', {name: 'Investigate'}); + expect(investigate).toHaveAttribute( 'href', `/organizations/${organization.slug}/issues/2/?seerDrawer=true` ); }); - it('shows merged state and enables the Merged PRs card when the API returns PR state', async () => { + it('shows a merged tag instead of a review action for merged runs', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ @@ -389,79 +445,57 @@ describe('AutofixOverview', () => { ], }); - const {router} = renderPage(); + renderPage(); - // The merged run wears a Merged tag instead of a Review PR action. - expect(await screen.findByText('Merged')).toBeInTheDocument(); + // Two "Merged" labels once the runs fetch resolves: the always-present + // Merged section header and the card's merged status tag. + await waitFor(() => { + expect(screen.getAllByText('Merged')).toHaveLength(2); + }); + // The merged tag replaces the Review PR action. expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); - - // The Merged PRs stat card is live and counts the row. - const mergedCard = screen.getByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('1'); - - await userEvent.click(mergedCard); - expect(router.location.query.quick).toBe('merged'); }); - it('orders cards as a triage queue: actionable, then working, then merged', async () => { - // A merged, B awaiting PR review, C still processing → B, C, A. - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/`, - body: [ - GroupFixture({id: '2', title: 'Issue A'}), - GroupFixture({id: '3', title: 'Issue B'}), - GroupFixture({id: '4', title: 'Issue C'}), - ], - }); - const runFor = (groupId: string, pullRequests: unknown[]) => ({ - id: `run-${groupId}`, - type: 'explorer', - groupId, - source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - pullRequests, - outputs: [], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - runFor('2', [{status: 'merged', mergedAt: '2026-07-15T09:00:00Z'}]), - runFor('3', []), - runFor('4', []), - ], - }); - // A and B both reached an opened PR; A's merged flag comes from its run. - for (const issueId of ['2', '3']) { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/${issueId}/autofix/`, - body: {autofix: autofixState}, - }); - } - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/4/autofix/`, - body: { - autofix: {...autofixState, status: 'processing', repo_pr_states: {}}, - }, - }); - + it('collapses sections individually and in bulk', async () => { renderPage(); - expect(await screen.findByText('Merged')).toBeInTheDocument(); - const titles = screen - .getAllByRole('link') - .map(link => link.textContent) - .filter(text => text === 'Issue A' || text === 'Issue B' || text === 'Issue C'); - expect(titles).toEqual(['Issue B', 'Issue C', 'Issue A']); - }); + const reviewHeader = await screen.findByRole('button', { + name: 'Awaiting your review 3', + }); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); - it('always enables the Merged PRs card, showing 0 when nothing is merged', async () => { - renderPage(); + // Collapsing a section hides only its cards. + await userEvent.click(reviewHeader); + expect( + screen.queryByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).not.toBeInTheDocument(); - const mergedCard = await screen.findByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('0'); + await userEvent.click(reviewHeader); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + + // The bulk toggle folds everything, then flips to Expand all. + await userEvent.click(screen.getByRole('button', {name: 'Collapse all'})); + expect( + screen.queryByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Expand all'})); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); }); it('surfaces the blocking question when a run awaits user input', async () => { @@ -503,39 +537,215 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); - it('normalizes space-less • bullets into a markdown list', async () => { + it('scopes the section requests to the selected projects', async () => { + PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: [2]})); + const reviewRequest = mockSection(SECTION_QUERIES.review_pr, { + body: [issue], + hits: '3', + }); + + renderPage(); + + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + // The selector's trigger reflects the selection (the card's project badge + // is a link, so the button role isolates the filter)… + expect(screen.getByRole('button', {name: 'project-slug'})).toBeInTheDocument(); + // …and the section request carries it. + expect(reviewRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/issues/`, + expect.objectContaining({ + query: expect.objectContaining({project: [2]}), + }) + ); + }); + + it('renders an inline differ for small diffs, collapsed to a file header', async () => { MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: '2026-07-14T10:00:00Z', + blocks: [ { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - // No space after the • — the normalizer must still split these. - answer: '•Confirm the header is not leaked. •Verify both headers work.', + id: 'b1', + timestamp: '2026-07-14T09:00:00Z', + message: { + role: 'assistant', + content: 'code', + metadata: {step: 'code_changes'}, + }, + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 2, + removed: 1, + hunks: [ + { + section_header: 'def add_to_cart', + source_start: 10, + source_length: 3, + target_start: 10, + target_length: 4, + lines: [ + { + value: 'def add_to_cart(item):', + line_type: ' ', + source_line_no: 10, + target_line_no: 10, + diff_line_no: 1, + }, + { + value: ' total = None', + line_type: '-', + source_line_no: 11, + target_line_no: null, + diff_line_no: 2, + }, + { + value: ' total = 0', + line_type: '+', + source_line_no: null, + target_line_no: 11, + diff_line_no: 3, + }, + { + value: ' return total', + line_type: '+', + source_line_no: null, + target_line_no: 12, + diff_line_no: 4, + }, + ], + }, + ], + }, + }, + ], }, ], }, - ], + }, }); renderPage(); - await userEvent.click(await screen.findByRole('button', {name: 'Full analysis'})); + // The differ's file header shows on the card without any interaction… + const fileHeader = await screen.findByText('src/cart.py'); + // …but the diff body starts collapsed. + expect(screen.queryByText(/@@ -10,3 \+10,4 @@/)).not.toBeInTheDocument(); + + await userEvent.click(fileHeader); + + expect(screen.getByText(/@@ -10,3 \+10,4 @@/)).toBeInTheDocument(); + }); + + it('focuses a single fully-expanded card when id is present', async () => { + // The focus fetch pins the exact group id (and the endpoint ignores the + // section filters in that mode). + const groupRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + body: [issue], + match: [MockApiClient.matchQuery({group: ['2']})], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: '2026-07-14T10:00:00Z', + blocks: [ + { + id: 'b1', + timestamp: '2026-07-14T09:00:00Z', + message: { + role: 'assistant', + content: 'code', + metadata: {step: 'code_changes'}, + }, + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 1, + removed: 0, + hunks: [ + { + section_header: '', + source_start: 5, + source_length: 1, + target_start: 5, + target_length: 2, + lines: [ + { + value: ' return total', + line_type: '+', + source_line_no: null, + target_line_no: 5, + diff_line_no: 1, + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }, + }); + + renderPage({id: '2'}); - expect(screen.getByText('Confirm the header is not leaked.')).toBeVisible(); - expect(screen.getByText('Verify both headers work.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); + // The full analysis renders expanded without any interaction… + expect(await screen.findByText('Root cause')).toBeVisible(); + expect(screen.getByText('PROJ-1')).toBeVisible(); + // …and so does the inline diff. + expect(screen.getByText(/@@ -5,1 \+5,2 @@/)).toBeInTheDocument(); + expect(groupRequest).toHaveBeenCalled(); + + // Focus mode hides the section list and offers the way back, keeping the + // other params. + expect( + screen.queryByRole('button', {name: /Awaiting your review/}) + ).not.toBeInTheDocument(); + const backLink = screen.getByRole('button', {name: 'All issues'}); + expect(backLink).toHaveAttribute('href', expect.not.stringContaining('id=2')); + }); + + it('shows the empty state when every section resolves empty', async () => { + // Override the seeded review bucket so all five sections are empty. + mockSection(SECTION_QUERIES.review_pr, {body: []}); + + renderPage(); + + expect(await screen.findByText('No completed autofix runs yet.')).toBeInTheDocument(); + // The section list is replaced entirely by the empty state. + expect( + screen.queryByRole('button', {name: /Awaiting your review/}) + ).not.toBeInTheDocument(); }); - it('renders an error state and can retry', async () => { + it('renders an error state only when every section fails', async () => { + // An unmatched mock is newest, so it answers all five section requests. MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, statusCode: 500, diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 1bb3a8d9b70f..fc63143e4d42 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -1,78 +1,49 @@ -import {useMemo} from 'react'; import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import {Alert} from '@sentry/scraps/alert'; +import {Badge} from '@sentry/scraps/badge'; import {Button, LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Disclosure} from '@sentry/scraps/disclosure'; +import {InfoTip} from '@sentry/scraps/info'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {Pagination} from '@sentry/scraps/pagination'; -import {Heading, Text} from '@sentry/scraps/text'; +import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; import Feature from 'sentry/components/acl/feature'; +import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; +import {PageFilterBar} from 'sentry/components/pageFilters/pageFilterBar'; +import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {IconFilter, IconFix, IconMerge, IconPullRequest, IconUser} from 'sentry/icons'; +import {Sticky} from 'sentry/components/sticky'; +import {IconArrow, IconChevron, IconGrid, IconTable} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {decodeList, decodeScalar} from 'sentry/utils/queryString'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {decodeScalar} from 'sentry/utils/queryString'; +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {useAutofixIssues} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; +import {SectionIssueCard} from './sectionIssueCard'; +import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './statusGroups'; import { - ATTENTION_META, - ATTENTION_REASONS, - getAttentionReason, - getTriageRank, -} from './attentionBadge'; -import {buildOverviewRows} from './buildOverviewRows'; -import {IssueCard} from './issueCard'; -import {RUN_QUESTION_PROMPTS} from './runQuestions'; -import type {AttentionReason, AutofixOutcome} from './types'; + SECTION_ORDER, + useAutofixSections, + type OverviewIssue, +} from './useAutofixSections'; -// Only autofix runs. `source` is the run's origin surface (autofix, chat, -// night_shift orchestration, ...), not the autofix trigger — so this keeps -// autofixes (including night-shift-triggered ones, which are source=autofix) -// and drops non-autofix runs like the night-shift triage feature run. How the -// autofix was triggered lives in the run's referrer/auto_run_source, which the -// API doesn't expose yet; badging/filtering by it is a follow-up. -const OVERVIEW_RUNS_QUERY = 'type:explorer source:autofix'; - -const OUTCOME_FILTER_OPTIONS: Array<{label: string; value: AutofixOutcome}> = [ - {value: 'root_cause', label: t('Root cause')}, - {value: 'solution', label: t('Solution')}, - {value: 'code_changes', label: t('Code changes')}, - {value: 'pr_opened', label: t('PR opened')}, -]; - -// TODO(seer): Re-enable the "Triggered by" filter once the backend exposes the -// autofix trigger. A run's `source` is its origin surface (always "autofix" -// here after the source:autofix filter), not how the autofix was triggered — -// that lives in the referrer / auto_run_source, which the runs API does not -// return yet. Until it does, this filter can only ever resolve to "manual", so -// it (and its options/parse/check/dropdown below) is disabled. -// const TRIGGER_FILTER_OPTIONS: Array<{label: string; value: AutofixTrigger}> = -// SELECTABLE_TRIGGERS.map(value => ({ -// value, -// label: TRIGGER_META[value].label, -// })); - -const ATTENTION_FILTER_OPTIONS: Array<{ - label: string; - value: AttentionReason; -}> = ATTENTION_REASONS.map(value => ({ - value, - label: ATTENTION_META[value].label, -})); - -type QuickFilterValue = 'review_pr' | 'awaiting_input' | 'code_changes_ready' | 'merged'; - -type SortValue = 'triage' | 'activity' | 'events'; +type SortValue = 'activity' | 'events'; +type OverviewView = 'cards' | 'table'; const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ - {value: 'triage', label: t('Needs you first')}, {value: 'activity', label: t('Recent activity')}, {value: 'events', label: t('Most events')}, ]; @@ -84,31 +55,38 @@ const PERIOD_FILTER_OPTIONS: Array<{label: string; value: string}> = [ {value: '30d', label: t('Last 30 days')}, ]; -const PERIOD_TO_DAYS: Record = { - '24h': 1, - '7d': 7, - '30d': 30, -}; - export default function AutofixOverview() { const organization = useOrganization(); const location = useLocation(); const navigate = useNavigate(); - const cursor = decodeScalar(location.query.cursor); - const outcomeFilter = decodeList(location.query.outcome) as AutofixOutcome[]; - // TODO(seer): trigger filter disabled — see TRIGGER_FILTER_OPTIONS above. - // const triggerFilter = decodeList(location.query.trigger) as AutofixTrigger[]; - const attentionFilter = decodeList(location.query.attention) as AttentionReason[]; - const quickFilter = decodeScalar(location.query.quick) as QuickFilterValue | undefined; + // Deep-link focus mode: ?id= renders exactly that issue's card, + // fully expanded, fetched by group id so it resolves even outside the + // list's filters. + const selectedId = decodeScalar(location.query.id); const period = decodeScalar(location.query.period); - const sort = (decodeScalar(location.query.sort) as SortValue | undefined) ?? 'triage'; + // Legacy ?sort=triage (and anything unknown) decodes to the default. + const sort = decodeScalar(location.query.sort) === 'events' ? 'events' : 'activity'; + + // Project scoping comes from the canonical page-filters selection; the + // section requests are gated until the persisted selection is restored so + // the first fetch doesn't race it with an all-projects query. + const {selection, isReady: pageFiltersReady} = usePageFilters(); + + const {sections, isPending, isError, refetch} = useAutofixSections({ + enabled: pageFiltersReady && !selectedId, + projects: selection.projects, + sort: sort === 'events' ? 'freq' : 'date', + statsPeriod: period || '90d', + }); - const {issues, isPending, isError, refetch, pageLinks} = useAutofixIssues({ - query: '', - cursor, - runsQuery: OVERVIEW_RUNS_QUERY, - questions: RUN_QUESTION_PROMPTS, + const pinnedIssueQuery = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/issues/', { + path: {organizationIdOrSlug: organization.slug}, + query: {group: [selectedId ?? ''], project: -1}, + staleTime: 30_000, + }), + enabled: Boolean(selectedId), }); const updateQuery = (patch: Record) => { @@ -121,104 +99,28 @@ export default function AutofixOverview() { ); }; - const toggleQuickFilter = (value: QuickFilterValue) => { - updateQuery({quick: quickFilter === value ? undefined : value}); - }; - - const periodCutoffMs = useMemo(() => { - const days = PERIOD_TO_DAYS[period ?? '']; - return days === undefined ? null : Date.now() - days * 24 * 60 * 60 * 1000; - }, [period]); - - // Computed each render (not memoized): the hook's enriched issues array is a - // new reference every render and there is at most a page's worth of rows. - const rowsWithAttention = buildOverviewRows(issues).map(row => ({ - row, - attention: getAttentionReason(row), - })); - - const filteredRows = rowsWithAttention.filter(({row, attention}) => { - if (outcomeFilter.length && !outcomeFilter.every(o => row.outcomes.includes(o))) { - return false; - } - // TODO(seer): trigger filter disabled — see TRIGGER_FILTER_OPTIONS above. - // if (triggerFilter.length && (!row.trigger || !triggerFilter.includes(row.trigger))) { - // return false; - // } - if (attentionFilter.length) { - if (!attention || !attentionFilter.includes(attention)) { - return false; - } - } - if (quickFilter === 'merged') { - if (!row.prMerged) { - return false; - } - } else if (quickFilter && attention !== quickFilter) { - return false; - } - if (periodCutoffMs !== null && Date.parse(row.lastActivityAt) < periodCutoffMs) { - return false; - } - return true; - }); - - // Default is the triage-queue order, what needs a human first - // (by urgency tier), highest impact within a tier, run recency as - // the tiebreak. - const byActivity = ( - a: (typeof filteredRows)[number], - b: (typeof filteredRows)[number] - ) => Date.parse(b.row.lastActivityAt) - Date.parse(a.row.lastActivityAt); - const sortedRows = [...filteredRows].sort((a, b) => { - if (sort === 'activity') { - return byActivity(a, b); - } - if (sort === 'events') { - return b.row.eventCount - a.row.eventCount || byActivity(a, b); - } - return ( - getTriageRank(a.row, a.attention) - getTriageRank(b.row, b.attention) || - b.row.eventCount - a.row.eventCount || - byActivity(a, b) + const [collapsedGroups, setCollapsedGroups] = useLocalStorageState( + 'seer-autofix-overview:collapsed-groups', + [] + ); + const [view, setView] = useLocalStorageState( + 'seer-autofix-overview:view', + storedValue => (storedValue === 'table' ? 'table' : 'cards') + ); + const toggleGroup = (groupKey: StatusGroupKey, expanded: boolean) => { + setCollapsedGroups(previous => + expanded + ? previous.filter(key => key !== groupKey) + : [...previous.filter(key => key !== groupKey), groupKey] ); - }); - - const stats = { - reviewPr: 0, - awaitingInput: 0, - codeChangesReady: 0, - merged: 0, }; - for (const {row, attention} of filteredRows) { - if (row.prMerged) { - stats.merged++; - } - if (attention === 'review_pr') { - stats.reviewPr++; - } - if (attention === 'awaiting_input') { - stats.awaitingInput++; - } - if (attention === 'code_changes_ready') { - stats.codeChangesReady++; - } - } - - const hasActiveFilters = - outcomeFilter.length > 0 || - attentionFilter.length > 0 || - quickFilter !== undefined || - (period !== undefined && period !== ''); + const allGroupsCollapsed = SECTION_ORDER.every(key => collapsedGroups.includes(key)); - const clearAllFilters = () => { - updateQuery({ - outcome: undefined, - attention: undefined, - quick: undefined, - period: undefined, - }); - }; + const firstLoad = isPending && sections.every(section => section.isPending); + const allSectionsEmpty = sections.every( + section => !section.isPending && section.issues.length === 0 + ); + const pinnedIssues = pinnedIssueQuery.data ?? []; return ( } > - - - - - {t('Autofix Overview')} - - {t( - 'Issues where Autofix has produced a root cause, solution, code changes, or pull request.' - )} - - - - - {t('Workflow runs')} - - - {t('Runs demo')} - - - - - - - toggleQuickFilter('awaiting_input')} - /> - toggleQuickFilter('code_changes_ready')} - /> - toggleQuickFilter('review_pr')} - /> - toggleQuickFilter('merged')} - /> - - + + + {/* The title lives in the app's slim top bar (Layout.Title fills the + TopBar slot); the description rides along as its info tip. */} + + {t('Autofix Overview')} + + + + {/* Focus mode swaps the filter toolbar for a way back to the + list; every other param (project, sort, ...) is preserved. */} + {selectedId ? ( + + } + to={{ + pathname: location.pathname, + query: {...location.query, id: undefined}, + }} + > + {t('All issues')} + + + ) : ( - - - updateQuery({ - outcome: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - {/* TODO(seer): "Triggered by" filter disabled until the runs - API exposes the autofix trigger (referrer/auto_run_source); - see TRIGGER_FILTER_OPTIONS above. - - updateQuery({ - trigger: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> */} - - updateQuery({ - attention: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> + + + - {hasActiveFilters ? ( - - ) : null} + + size="xs" + value={view} + onChange={setView} + aria-label={t('View mode')} + > + } + aria-label={t('Card view')} + tooltip={t('Card view')} + /> + } + aria-label={t('Table view')} + tooltip={t('Table view')} + /> + + - + )} - {isError ? ( + {selectedId ? ( + pinnedIssueQuery.isError ? ( + + ) : pinnedIssueQuery.isPending ? ( + + ) : pinnedIssues.length === 0 ? ( + + + {t('Issue not found.')} + + + ) : ( + + {pinnedIssues.map(issue => ( + + ))} + + ) + ) : isError ? ( - ) : isPending ? ( + ) : firstLoad ? ( - ) : sortedRows.length === 0 ? ( + ) : allSectionsEmpty ? ( - {hasActiveFilters - ? t('No issues match your filters.') - : t('No completed autofix runs yet.')} + {t('No completed autofix runs yet.')} ) : ( - - {sortedRows.map(({row}) => ( - - ))} + + {sections.map(section => { + const meta = STATUS_GROUP_META[section.key]; + return ( + toggleGroup(section.key, next)} + > + + + + {/* The pipeline tooltip lives on the section + icon — every card in a group shares its + stage, so the checklist is group-level info */} + } + skipWrapper + > + + + {meta.label} + {section.count ?? '…'} + + + + + {section.isPending ? ( + + ) : section.issues.length === 0 ? ( + + + {t('No issues')} + + + ) : ( + + {section.issues.map((issue, index) => ( + + ))} + + )} + + + ); + })} )} - - {!isPending && !isError && } - - - + + + ); } +// Disclosure.Content indents its panel to sit under the title text (a +// hardcoded padding-left in the core component). The panel here is full-width +// cards, so that indent shoves them right of the sticky header box — drop it +// (it's the content sibling after the header) so the cards line up flush with +// their group. +const StatusGroup = styled(Disclosure)` + && > * + * { + padding-left: 0; + } +`; + +// Linear-style section header: parks below the top bar while its group +// scrolls, then gets pushed away by the next header (sticky is bounded by +// its group's box). Opaque so cards slide underneath cleanly; z-index isn't +// a layout-primitive prop, hence the styled override. +const GroupHeader = styled(Sticky)` + z-index: ${p => p.theme.zIndex.initial}; + width: 100%; + background: ${p => p.theme.tokens.background.secondary}; + border-radius: ${p => p.theme.radius.md}; + + &[data-stuck] { + border-radius: 0; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + } +`; + function NoAccess() { return ( @@ -435,68 +381,3 @@ function NoAccess() { ); } - -type StatIconVariant = 'success' | 'warning' | 'primary' | 'secondary'; - -const StatCardButton = styled('button')<{isActive: boolean}>` - cursor: ${p => (p.disabled ? 'default' : 'pointer')}; - background: ${p => - p.isActive ? p.theme.tokens.background.secondary : p.theme.tokens.background.primary}; - border: 1px solid - ${p => (p.isActive ? p.theme.tokens.border.accent : p.theme.tokens.border.primary)}; - border-radius: ${p => p.theme.radius.md}; - padding: ${p => `${p.theme.space.md} ${p.theme.space.lg}`}; - text-align: left; - transition: - border-color 0.15s ease, - background 0.15s ease; - &:hover { - border-color: ${p => - p.disabled ? p.theme.tokens.border.primary : p.theme.tokens.border.accent}; - } - &:focus-visible { - outline: 2px solid ${p => p.theme.tokens.focus.default}; - outline-offset: 1px; - } -`; - -function StatCard({ - Icon, - iconVariant, - label, - value, - isActive, - onClick, - extra, -}: { - Icon: typeof IconUser; - iconVariant: StatIconVariant; - isActive: boolean; - label: string; - value: number | string; - extra?: React.ReactNode; - onClick?: () => void; -}) { - return ( - - - - - - {label} - - {extra} - - - {value} - - - - ); -} diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 14d62aec55cb..9b4780324e49 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -2,9 +2,8 @@ import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; import {LinkButton} from '@sentry/scraps/button'; -import {Disclosure} from '@sentry/scraps/disclosure'; import {InfoText} from '@sentry/scraps/info'; -import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -15,7 +14,6 @@ import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {TimeSince} from 'sentry/components/timeSince'; import { IconArrow, - IconCircleCheckmark, IconCommit, IconFocus, IconMerge, @@ -25,6 +23,7 @@ import { import {t, tn} from 'sentry/locale'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ellipsize} from 'sentry/utils/string/ellipsize'; +import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; import {ATTENTION_META, AttentionBadge, getAttentionReason} from './attentionBadge'; import {TriggerBadge} from './triggerBadge'; @@ -41,6 +40,18 @@ const TitleLink = styled(Link)` // The most-changed files shown on hover before collapsing into "+N more". const MAX_TOOLTIP_FILES = 5; +// Paths have no spaces to wrap on, so a long one would push the +/− counts +// out of the tooltip's max width. Truncate from the LEFT (rtl trick, like the +// diff viewer's file header) so the filename end stays visible; overflow +// hidden also gives the flex item its min-width of 0. +const TooltipPath = styled(Text)` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; +`; + // Per-file breakdown for the diff pill's tooltip: path left, churn right, // biggest files first (fileList is pre-sorted by churn). function PatchFilesTooltip({stats}: {stats: PatchStats}) { @@ -50,9 +61,9 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { {shown.map(file => ( - + {file.path} - + +{file.added} @@ -72,37 +83,52 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { ); } -// Buckets the raw 0–1 score into a scannable label; the 0.7 threshold matches -// isIssueQuickFixable (sentry/components/events/autofix/utils). -function FixabilityTag({score}: {score: number}) { - const high = score > 0.7; - const label = high - ? t('High fixability') - : score > 0.4 - ? t('Medium fixability') - : t('Low fixability'); - return ( - - {label} - - ); -} - -export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { +export function IssueCard({ + orgSlug, + row, + defaultExpanded = false, +}: { + orgSlug: string; + row: OverviewRow; + // Open the inline diffs on mount — the overview's ?id= focus mode wants + // the whole card readable at once. + defaultExpanded?: boolean; +}) { const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; // Deep-link into the issue page with the Seer drawer already open, so the // run itself is one click away (matches the issue details ?seerDrawer param). const runUrl = {pathname: issueUrl, query: {seerDrawer: 'true'}}; const attention = getAttentionReason(row); - // The body shows exactly one block: the proposed fix when the run drafted - // code (the fix prompt returns an empty answer otherwise, and empty answers - // never become entries), else the diagnosis summary. - const summary = row.analysis.find(entry => entry.key === 'summary'); + const rootCause = row.analysis.find(entry => entry.key === 'root_cause'); const proposedFix = row.analysis.find(entry => entry.key === 'fix_summary'); - const bodyEntry = proposedFix ?? summary; - const isFixBody = bodyEntry?.key === 'fix_summary'; - const detailEntries = row.analysis.filter(entry => entry.placement === 'details'); + const reviewerNotes = row.analysis.find(entry => entry.key === 'reviewer_notes'); + // Thought order: what broke → what Seer changed → what the human does + // next. The fix and next-step prompts return empty answers when they don't + // apply, and empty answers never become entries. + const sections = [ + rootCause && { + key: 'root_cause', + label: t('Root cause'), + icon: , + variant: 'muted' as const, + answer: rootCause.answer, + }, + proposedFix && { + key: 'fix_summary', + label: t('Proposed fix'), + icon: , + variant: 'success' as const, + answer: proposedFix.answer, + }, + reviewerNotes && { + key: 'reviewer_notes', + label: t('Next steps'), + icon: , + variant: 'muted' as const, + answer: reviewerNotes.answer, + }, + ].filter(section => !!section); const eventCountLabel = row.eventCount === 1 ? t('1 event') @@ -114,18 +140,21 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { return ( - - {/* Header: title + change size + action */} + {/* lg between blocks gives the sections air; each section binds its + eyebrow to its prose with a tight xs gap */} + + {/* Header: title over its metadata subline on the left, diff size + + progress ring pinned right */} - - + {/* The ellipsis Text is the shrinking flex item (overflow:hidden resolves its min-width to 0); the Link must nest inside it or the anchor refuses to shrink and the title overflows the card. When Seer produced a plain-language headline it replaces the raw issue title, which stays reachable via the tooltip and the expanded details. */} - + {/* lg matches the issues feed's row titles */} + {row.headline ? ( {row.title} )} - + {/* Metadata subline: the run's vitals as a quiet dot-separated + run tucked under the title. "Manual" is the default trigger + and reads as noise on every card, so only non-default + triggers earn their badge. */} + + 0 + ? t( + '%s events and %s affected users in the last 90 days', + row.eventCount.toLocaleString(), + row.userCount.toLocaleString() + ) + : t('%s events in the last 90 days', row.eventCount.toLocaleString()) + } + size="sm" + variant="muted" + > + {eventCountLabel} + {row.userCount > 0 && ` · ${userCountLabel}`} + + + {'·'} + + + + + {row.trigger !== 'manual' && ( + + )} + + + {/* Right cluster: just the diff-size fact — the action lives in + the card's tail, the pipeline story on the group header */} - {/* No stage chip here: the action verb already encodes the stage - (Review PR ⇒ PR opened, Open PR ⇒ code drafted, …) and the - Outcome filter covers querying by it. One fact + one action. */} {row.patchStats && ( } @@ -177,212 +240,75 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { )} - {row.statePending ? ( - {'…'} - ) : row.isProcessing ? ( - {t('Running')} - ) : row.prMerged ? ( - - }> - {t('Merged')} - - - ) : attention === 'review_pr' && row.prUrl ? ( - - } - href={row.prUrl} - external - > - {ATTENTION_META.review_pr.label} - - - ) : attention ? ( - - ) : ( - - - {t('View run')} - - - )} - {row.prUrl && attention !== 'review_pr' && ( - } - href={row.prUrl} - external - > - {row.prNumber ? `#${row.prNumber}` : t('PR')} - - )} {/* The question autofix is blocked on, surfaced right on the card */} {row.pendingQuestion && ( - + {t('Seer asked: %s', row.pendingQuestion)} )} - {/* The body is exactly ONE block, either/or: the proposed fix when the - run drafted code (the fix text supersedes the summary, which would - describe the same change twice), otherwise the diagnosis summary. - Same anatomy for both; icon + label color tell them apart. */} - {bodyEntry && ( - - - - {isFixBody ? ( - - ) : ( - - )} - - {isFixBody ? t('Proposed fix') : t('Diagnosis')} - - - - + {/* The analysis sections, one shared voice (eyebrow icon + + uppercase label + prose), in thought order */} + {sections.map(section => ( + + + {section.icon} + + {section.label} - - + + + + + + ))} + + {/* The drafted diff itself, but only when it's small enough to read + on a card (see the INLINE_DIFF_* limits): collapsed file headers + that expand in place, aligned with the body's text column */} + {row.inlinePatches && ( + + {row.inlinePatches.map(({patch, repoName}) => ( + + ))} + )} - {/* Footer: the collapsed analysis on the left, project pinned in the - card's bottom-right corner */} - - - {detailEntries.length > 0 && ( - - {t('Full analysis')} - - - {/* Compact identity strip: the short id and Seer's - fixability read — the raw title lives in the headline - tooltip, not here */} - - - {row.shortId} - - {typeof row.fixabilityScore === 'number' && ( - - )} - - {/* Sections share the body blocks' icon+label voice and - sit side by side on wide screens instead of leaving - the card's right half empty */} - - {detailEntries.map(entry => { - const section = - entry.key === 'reviewer_notes' - ? isFixBody - ? { - label: t('Review checklist'), - icon: ( - - ), - } - : { - label: t('Next steps'), - icon: ( - - ), - } - : { - label: entry.label, - icon: , - }; - return ( - - - {section.icon} - - {section.label} - - - - - - - ); - })} - - - - - )} - - {/* Provenance + vitals read as one quiet metadata line */} - - {/* "Manual" is the default trigger and reads as noise on every - card; only non-default triggers earn a badge */} - {row.trigger !== 'manual' && ( - - )} - - 0 - ? t( - '%s events and %s affected users in the last 90 days', - row.eventCount.toLocaleString(), - row.userCount.toLocaleString() - ) - : t('%s events in the last 90 days', row.eventCount.toLocaleString()) - } - size="xs" - variant="muted" + {/* Tail row: the run's action anchors the bottom left; identity + (level, short id, project) reads as quiet provenance on the + right, the project linking to its page */} + + + + {row.prUrl && attention !== 'review_pr' && ( + } + href={row.prUrl} + external > - {eventCountLabel} - {row.userCount > 0 && ` · ${userCountLabel}`} - - - {'·'} - - - - - - - + {row.prNumber ? `#${row.prNumber}` : t('PR')} + + )} + + + + + {row.shortId} + + + @@ -390,3 +316,155 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { ); } + +/** + * The compact, Linear-style rendering used by the overview's table mode. + * Keep this deliberately sparse: the full analysis and diff stay in card + * mode, while this row is optimized for scanning and taking the next action. + */ +export function IssueTableRow({ + isLast, + orgSlug, + row, +}: { + isLast: boolean; + orgSlug: string; + row: OverviewRow; +}) { + const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; + const runUrl = {pathname: issueUrl, query: {seerDrawer: 'true'}}; + const eventCountLabel = + row.eventCount === 1 + ? t('1 event') + : t('%s events', formatAbbreviatedNumber(row.eventCount)); + const userCountLabel = + row.userCount === 1 + ? t('1 user') + : t('%s users', formatAbbreviatedNumber(row.userCount)); + + return ( + + + + {row.headline ? ( + + + {t('Raw issue title')} + + + {ellipsize(row.title, 200)} + + + } + > + {row.headline} + + ) : ( + {row.title} + )} + + + + + {row.shortId} + + + {'·'} + + + {eventCountLabel} + {row.userCount > 0 && ` · ${userCountLabel}`} + + + {'·'} + + + + + + + + + + + ); +} + +function IssuePrimaryAction({ + row, + runUrl, +}: { + row: OverviewRow; + runUrl: {pathname: string; query: {seerDrawer: string}}; +}) { + const attention = getAttentionReason(row); + + if (row.statePending) { + return {'…'}; + } + if (row.isProcessing) { + return {t('Running')}; + } + if (row.prMerged) { + return ( + + }> + {t('Merged')} + + + ); + } + if (attention === 'review_pr' && row.prUrl) { + return ( + + } + href={row.prUrl} + external + > + {ATTENTION_META.review_pr.label} + + + ); + } + if (attention) { + return ; + } + // Diagnosis-only runs: Seer found a root cause but produced nothing to + // ship, so the human's move is to dig in — same verb (and icon) as the + // Needs-investigation group these rows live in. + return ( + + } to={runUrl}> + {t('Investigate')} + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/runQuestions.tsx b/static/app/views/seerWorkflows/overview/runQuestions.tsx index 6ffa35460f5f..43dbc88f6158 100644 --- a/static/app/views/seerWorkflows/overview/runQuestions.tsx +++ b/static/app/views/seerWorkflows/overview/runQuestions.tsx @@ -1,11 +1,8 @@ import {t} from 'sentry/locale'; -import type {AnswerPlacement} from './types'; - interface RunQuestionConfig { key: string; label: string; - placement: AnswerPlacement; // The prompt sent to the runs endpoint as a `question` param. Answered per // run over its full history by Seer's agent_question one-shot; editing the // text invalidates that (run, question) cache entry, so fresh answers show @@ -20,38 +17,23 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ { key: 'root_cause', label: t('Root cause'), - placement: 'details', prompt: 'Answer in two parts separated by a single pipe character. First a ' + 'headline: at most 14 words of plain language describing what is broken ' + 'and where, the way a good bug-report title would — no error class names ' + 'unless essential, no markdown or code formatting, no pipe characters in ' + 'it. ' + - 'Then the pipe. Then the root cause: what actually breaks and why, in ' + - 'one or two short sentences (at most 40 words), naming the responsible ' + - 'function, commit, or configuration — inline code is allowed in this ' + - 'part, but no headers, bullets, or code blocks. If the run did not ' + - 'establish a root cause, still give the headline, then the pipe, then ' + - 'one sentence on why it could not (e.g. could not reproduce, missing ' + - 'context).', - }, - { - key: 'summary', - label: t('Summary'), - placement: 'face', - prompt: - 'One sentence of at most 25 words (a semicolon is fine): the failure ' + - "mechanism, then the run's concrete outcome — what the opened PR or " + - 'drafted fix changes and where, or the issue-specific reason the run ' + - 'skipped or stopped early. Name files and identifiers, not activities: ' + - 'never content-free phrases like "this run produced a diagnosis" or ' + - '"implemented code changes". No first person, no filler. Inline code ' + - 'allowed; no headers, bullets, or code blocks.', + 'Then the pipe. Then the root cause: ONE sentence of at most 25 words on ' + + 'why it breaks, naming the responsible function, commit, or ' + + 'configuration — the headline already covers what and where, so do not ' + + 'repeat it. Inline code is allowed in this part, but no headers, ' + + 'bullets, or code blocks. If the run did not establish a root cause, ' + + 'still give the headline, then the pipe, then one sentence on why it ' + + 'could not (e.g. could not reproduce, missing context).', }, { key: 'fix_summary', label: t('Proposed fix'), - placement: 'face', prompt: 'If this run drafted code changes or opened a pull request, describe in ' + 'at most two sentences (max 45 words) what the changes are and why they ' + @@ -65,22 +47,16 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ }, { key: 'reviewer_notes', - // Placeholder — the card derives the display label from whether the run - // drafted code: "Review checklist" vs "Next steps". - label: t('Notes'), - placement: 'details', + label: t('Next steps'), prompt: - 'If this run drafted code changes or opened a pull request: a review ' + - 'checklist — three to five markdown bullets a reviewer should verify ' + - 'before trusting or merging the change, each naming the specific risk, ' + - 'assumption, or untested path and why it matters (at most 25 words per ' + - 'bullet). If the run produced no code: next steps instead — two to four ' + - 'markdown bullets on how an engineer should take this forward (what to ' + - 'confirm in the codebase, what decision to make, whether to have Seer ' + - 'generate code), concrete and specific to this issue, never generic ' + - 'advice. Every bullet must start on its own line with "- " (hyphen, ' + - 'space) — never use the "•" character or run bullets together in one ' + - 'paragraph. No first person; inline code allowed; no markdown headers.', + 'If this run drafted code changes or opened a pull request, return an ' + + 'empty string. Otherwise: ONE sentence of at most 25 words — the single ' + + 'highest-leverage next step for an engineer (what to confirm, decide, ' + + 'or investigate), concrete and specific to this issue. Never tell the ' + + 'reader to have Seer or an AI generate the fix — the product already ' + + 'offers that as a button next to these notes; if generating code is the ' + + 'obvious next move, state what the fix should do instead. No first ' + + 'person; no markdown bullets or headers; inline code allowed.', }, ]; diff --git a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx new file mode 100644 index 000000000000..930bf9b10572 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx @@ -0,0 +1,107 @@ +import {useQuery} from '@tanstack/react-query'; + +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {LazyRender} from 'sentry/components/lazyRender'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {RunQuestion} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; + +import {buildOverviewRow} from './buildOverviewRows'; +import {IssueCard, IssueTableRow} from './issueCard'; +import {RUN_QUESTION_PROMPTS} from './runQuestions'; +import type {OverviewIssue} from './useAutofixSections'; + +const RUNS_QUERY = 'type:explorer source:autofix'; +const CARD_PLACEHOLDER_HEIGHT = 180; +const TABLE_ROW_PLACEHOLDER_HEIGHT = 48; +const LAZY_OBSERVER_OPTIONS = {rootMargin: '200px 0px'}; + +interface SectionRun { + groupId: string | null; + id: string; + lastTriggeredAt: string; + source: string | null; + outputs?: RunQuestion[]; + pullRequests?: Array<{status: string | null}>; +} + +function useIssueCardContent(issueId: string) { + const organization = useOrganization(); + + const runsQuery = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/seer/runs/', { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: `${RUNS_QUERY} group:${issueId}`, + question: RUN_QUESTION_PROMPTS, + per_page: 1, + }, + staleTime: 30_000, + }), + }); + + const stateQuery = useQuery({ + ...apiOptions.as<{autofix: ExplorerAutofixState | null}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', + { + path: {organizationIdOrSlug: organization.slug, issueId}, + query: {mode: 'explorer'}, + staleTime: 30_000, + } + ), + }); + + return { + run: runsQuery.data?.find(run => run.groupId === issueId) ?? null, + state: stateQuery.data?.autofix ?? null, + statePending: stateQuery.isPending, + }; +} + +function HydratedCard({ + defaultExpanded, + issue, + orgSlug, + view, + isLast, +}: { + issue: OverviewIssue; + orgSlug: string; + view: 'cards' | 'table'; + defaultExpanded?: boolean; + isLast?: boolean; +}) { + const {run, state, statePending} = useIssueCardContent(issue.id); + const row = buildOverviewRow(issue, run, state, statePending); + + return view === 'cards' ? ( + + ) : ( + + ); +} + +export function SectionIssueCard({ + lazy = true, + ...props +}: { + issue: OverviewIssue; + orgSlug: string; + view: 'cards' | 'table'; + defaultExpanded?: boolean; + isLast?: boolean; + lazy?: boolean; +}) { + return ( + + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/statusGroups.tsx b/static/app/views/seerWorkflows/overview/statusGroups.tsx new file mode 100644 index 000000000000..6cec0d5e703b --- /dev/null +++ b/static/app/views/seerWorkflows/overview/statusGroups.tsx @@ -0,0 +1,117 @@ +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import { + IconCode, + IconCommit, + IconMerge, + IconPullRequest, + IconSearch, + IconSeer, + IconUser, + IconWarning, +} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {t, tn} from 'sentry/locale'; + +import type {AttentionReason} from './types'; + +// The list's Linear-style sections: the triage tiers made visible. Attention +// states reuse their keys; needs_investigation covers settled diagnosis-only +// runs (manual next steps, no one-click pipeline action), running/merged the +// rows with nothing left to do. +export type StatusGroupKey = + | AttentionReason + | 'needs_investigation' + | 'running' + | 'merged'; + +interface StatusGroupMeta { + Icon: React.ComponentType; + label: string; + // Description shown in the header icon's tooltip for groups whose members + // span pipeline stages; staged groups render the step checklist instead. + description?: string; + // How far through the pipeline every member of this group is (1-4). Unset + // for variable-stage groups and merged. + fill?: number; +} + +export const STATUS_GROUP_META: Record = { + awaiting_input: { + Icon: IconUser, + label: t('Needs your input'), + description: t( + 'Autofix paused and is asking for more information before it can proceed.' + ), + }, + review_pr: {Icon: IconPullRequest, label: t('Awaiting your review'), fill: 4}, + code_changes_ready: {Icon: IconCommit, label: t('Code changes ready'), fill: 3}, + solution_ready: {Icon: IconCode, label: t('Ready to generate code'), fill: 2}, + errored: { + Icon: IconWarning, + label: t('Errored'), + description: t('These runs errored. Open one to investigate or retry.'), + }, + // Same magnifier as the cards' Diagnosis block: these runs stopped at a + // diagnosis, and their Next-steps bullets are manual verify/decide work. + needs_investigation: {Icon: IconSearch, label: t('Needs investigation'), fill: 1}, + running: { + Icon: IconSeer, + label: t('Running'), + description: t('Seer is still working on these runs.'), + }, + merged: {Icon: IconMerge, label: t('Merged')}, +}; + +const STEP_LABELS = [ + t('Root cause'), + t('Plan'), + t('Code changes'), + t('PR opened'), + t('Merged'), +]; + +/** + * The pipeline checklist for a group's header icon: where every card in the + * group is and how many steps remain until the fix lands. Staged groups get + * the ✓/○ rows; variable-stage groups get their one-line description. + */ +export function StatusGroupTooltip({groupKey}: {groupKey: StatusGroupKey}) { + const meta = STATUS_GROUP_META[groupKey]; + const merged = groupKey === 'merged'; + const fill = merged ? STEP_LABELS.length : meta.fill; + + if (fill === undefined) { + return ( + + {meta.description} + + ); + } + + return ( + + + {merged + ? t('Issue fixed') + : tn( + '%s step until issue fix', + '%s steps until issue fix', + STEP_LABELS.length - fill + )} + + {STEP_LABELS.map((label, index) => + index < fill ? ( + + {`✓ ${label}`} + + ) : ( + + {`○ ${label}`} + + ) + )} + + ); +} diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 171583b7721e..ce68e7f452bb 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -1,3 +1,4 @@ +import type {FilePatch} from 'sentry/components/events/autofix/types'; import type {Level} from 'sentry/types/event'; import type {PlatformKey} from 'sentry/types/platform'; @@ -24,17 +25,12 @@ export type AttentionReason = | 'review_pr' | 'errored'; -// Where an answered run question renders on the card: on the face (always -// visible) or inside the collapsed "Full analysis" disclosure. -export type AnswerPlacement = 'face' | 'details'; - // One answered run question joined to its question config // See ./runQuestions.ts export interface RunAnalysisEntry { answer: string; key: string; label: string; - placement: AnswerPlacement; } // One changed file within the run's drafted diff. @@ -73,10 +69,13 @@ export interface OverviewRow { statePending: boolean; title: string; userCount: number; - fixabilityScore?: number | null; // Plain-language title from the run's root-cause answer (see runQuestions). // Falls back to the raw issue title. headline?: string; + // Structured patches for the on-card differ; present only when the diff is + // small enough to render inline (see the INLINE_DIFF_* limits in + // buildOverviewRows). + inlinePatches?: Array<{patch: FilePatch; repoName?: string}>; isProcessing?: boolean; patchStats?: PatchStats; // The question autofix paused on, when status is NEED_MORE_INFORMATION and diff --git a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx new file mode 100644 index 000000000000..cd64c326a6fd --- /dev/null +++ b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx @@ -0,0 +1,103 @@ +import {useQueries} from '@tanstack/react-query'; + +import type {Level} from 'sentry/types/event'; +import type {PlatformKey} from 'sentry/types/platform'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export type AutofixStateKey = + | 'review_pr' + | 'code_changes_ready' + | 'solution_ready' + | 'needs_investigation' + | 'merged'; + +export const SECTION_ORDER: AutofixStateKey[] = [ + 'review_pr', + 'code_changes_ready', + 'solution_ready', + 'needs_investigation', + 'merged', +]; + +const SECTION_LIMIT = 100; +const REQUIRED_ISSUE_FILTER = 'has:issue.seer_last_run'; + +export interface OverviewIssue { + count: string; + culprit: string; + id: string; + lastSeen: string; + level: Level; + project: {slug: string; platform?: PlatformKey}; + seerAutofixLastTriggered: string | null; + seerFixabilityScore: number | null; + shortId: string; + title: string; + userCount: number; +} + +export interface SectionResult { + count: number | undefined; + isError: boolean; + isPending: boolean; + issues: OverviewIssue[]; + key: AutofixStateKey; + refetch: () => void; +} + +export function useAutofixSections({ + enabled, + projects, + sort, + statsPeriod, +}: { + enabled: boolean; + projects: number[]; + sort: 'date' | 'freq'; + statsPeriod: string; +}) { + const organization = useOrganization(); + + const results = useQueries({ + queries: SECTION_ORDER.map(key => ({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: `${REQUIRED_ISSUE_FILTER} issue.autofix_state:${key}`, + project: projects, + statsPeriod, + sort, + limit: SECTION_LIMIT, + }, + staleTime: 30_000, + } + ), + enabled, + select: (data: ApiResponse) => selectJsonWithHeaders(data), + })), + }); + + const sections: SectionResult[] = SECTION_ORDER.map((key, index) => { + const result = results[index]!; + const issues = result.data?.json ?? []; + return { + key, + issues, + count: result.data?.headers['X-Hits'] ?? (result.data ? issues.length : undefined), + isPending: result.isPending, + isError: result.isError, + refetch: () => result.refetch(), + }; + }); + + return { + sections, + isPending: results.some(result => result.isPending), + isError: results.every(result => result.isError), + refetch: () => results.forEach(result => result.refetch()), + }; +} From 3e4df6f19e55c1d136c56192d32307bc390a8dd1 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 10:29:38 -0700 Subject: [PATCH 02/19] ref(seer): Remove dead code from the autofix overview The overview only ever renders the five AutofixStateKey status groups, so the awaiting_input/errored/running status-group metadata, their tooltip description path, and the widened StatusGroupKey union were unreachable. Drop them along with the unused ATTENTION_REASONS export, the write-only OverviewRow.lastSeen field, and the never-read culprit/seerFixabilityScore fields on OverviewIssue. --- .../seerWorkflows/overview/attentionBadge.tsx | 8 ---- .../overview/buildOverviewRows.ts | 1 - .../seerWorkflows/overview/statusGroups.tsx | 41 ++----------------- .../app/views/seerWorkflows/overview/types.ts | 1 - .../overview/useAutofixSections.tsx | 2 - 5 files changed, 3 insertions(+), 50 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx index 5591e9b4582c..1934e4db162d 100644 --- a/static/app/views/seerWorkflows/overview/attentionBadge.tsx +++ b/static/app/views/seerWorkflows/overview/attentionBadge.tsx @@ -11,14 +11,6 @@ import type {AttentionReason, OverviewRow} from './types'; type LinkButtonVariant = React.ComponentProps['variant']; -export const ATTENTION_REASONS: AttentionReason[] = [ - 'awaiting_input', - 'review_pr', - 'code_changes_ready', - 'solution_ready', - 'errored', -]; - export const ATTENTION_META: Record< AttentionReason, { diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 5a90ed496855..18994215bc84 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -255,7 +255,6 @@ export function buildOverviewRow( project: issue.project, eventCount: Number.isFinite(eventCount) ? eventCount : 0, userCount: issue.userCount, - lastSeen: issue.lastSeen, lastActivityAt: state?.updated_at ?? run?.lastTriggeredAt ?? diff --git a/static/app/views/seerWorkflows/overview/statusGroups.tsx b/static/app/views/seerWorkflows/overview/statusGroups.tsx index 6cec0d5e703b..0e2d62e41ff6 100644 --- a/static/app/views/seerWorkflows/overview/statusGroups.tsx +++ b/static/app/views/seerWorkflows/overview/statusGroups.tsx @@ -7,60 +7,33 @@ import { IconMerge, IconPullRequest, IconSearch, - IconSeer, - IconUser, - IconWarning, } from 'sentry/icons'; import type {SVGIconProps} from 'sentry/icons/svgIcon'; import {t, tn} from 'sentry/locale'; -import type {AttentionReason} from './types'; +import type {AutofixStateKey} from './useAutofixSections'; // The list's Linear-style sections: the triage tiers made visible. Attention // states reuse their keys; needs_investigation covers settled diagnosis-only // runs (manual next steps, no one-click pipeline action), running/merged the // rows with nothing left to do. -export type StatusGroupKey = - | AttentionReason - | 'needs_investigation' - | 'running' - | 'merged'; +export type StatusGroupKey = AutofixStateKey; interface StatusGroupMeta { Icon: React.ComponentType; label: string; - // Description shown in the header icon's tooltip for groups whose members - // span pipeline stages; staged groups render the step checklist instead. - description?: string; // How far through the pipeline every member of this group is (1-4). Unset // for variable-stage groups and merged. fill?: number; } export const STATUS_GROUP_META: Record = { - awaiting_input: { - Icon: IconUser, - label: t('Needs your input'), - description: t( - 'Autofix paused and is asking for more information before it can proceed.' - ), - }, review_pr: {Icon: IconPullRequest, label: t('Awaiting your review'), fill: 4}, code_changes_ready: {Icon: IconCommit, label: t('Code changes ready'), fill: 3}, solution_ready: {Icon: IconCode, label: t('Ready to generate code'), fill: 2}, - errored: { - Icon: IconWarning, - label: t('Errored'), - description: t('These runs errored. Open one to investigate or retry.'), - }, // Same magnifier as the cards' Diagnosis block: these runs stopped at a // diagnosis, and their Next-steps bullets are manual verify/decide work. needs_investigation: {Icon: IconSearch, label: t('Needs investigation'), fill: 1}, - running: { - Icon: IconSeer, - label: t('Running'), - description: t('Seer is still working on these runs.'), - }, merged: {Icon: IconMerge, label: t('Merged')}, }; @@ -80,15 +53,7 @@ const STEP_LABELS = [ export function StatusGroupTooltip({groupKey}: {groupKey: StatusGroupKey}) { const meta = STATUS_GROUP_META[groupKey]; const merged = groupKey === 'merged'; - const fill = merged ? STEP_LABELS.length : meta.fill; - - if (fill === undefined) { - return ( - - {meta.description} - - ); - } + const fill = meta.fill ?? STEP_LABELS.length; return ( diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index ce68e7f452bb..77ecda9351ba 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -59,7 +59,6 @@ export interface OverviewRow { // Most recent activity on the run (state update, trigger, or issue-level // last-trigger timestamp) - drives sorting and the period filter. lastActivityAt: string; - lastSeen: string; level: Level; outcomes: AutofixOutcome[]; prMerged: boolean; diff --git a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx index cd64c326a6fd..293e675abb17 100644 --- a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx +++ b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx @@ -26,13 +26,11 @@ const REQUIRED_ISSUE_FILTER = 'has:issue.seer_last_run'; export interface OverviewIssue { count: string; - culprit: string; id: string; lastSeen: string; level: Level; project: {slug: string; platform?: PlatformKey}; seerAutofixLastTriggered: string | null; - seerFixabilityScore: number | null; shortId: string; title: string; userCount: number; From a27b972e88503c8d8b28c8f338e4cd763305342f Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 10:39:38 -0700 Subject: [PATCH 03/19] fix(seer): Surface per-section fetch errors and honest period labels Sections carried isError/refetch that nothing rendered; a failed bucket now shows a retryable LoadingError inline while its siblings keep loading. The period filter's 'All time' option was a lie: statsPeriod fell back to '90d' either way. Replace it with an explicit 'Last 90 days' default and thread the active period through the row so the count tooltip states the real window instead of a hardcoded 90 days. lastActivityAt now takes the most recent of its candidate timestamps rather than the first defined one, and the diff-size pill is focusable so its churn tooltip is reachable by keyboard and assistive tech. --- .../overview/buildOverviewRows.ts | 25 +++++++++++++----- .../seerWorkflows/overview/index.spec.tsx | 21 +++++++++++++++ .../views/seerWorkflows/overview/index.tsx | 26 +++++++++---------- .../seerWorkflows/overview/issueCard.tsx | 18 ++++++++++--- .../views/seerWorkflows/overview/periods.ts | 17 ++++++++++++ .../overview/sectionIssueCard.tsx | 5 +++- .../app/views/seerWorkflows/overview/types.ts | 3 +++ 7 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/periods.ts diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 18994215bc84..3be181f818cd 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -237,11 +237,22 @@ export interface OverviewRunData { source?: string | null; } +function mostRecentTimestamp(...candidates: Array): string { + let latest = ''; + for (const candidate of candidates) { + if (candidate && candidate > latest) { + latest = candidate; + } + } + return latest; +} + export function buildOverviewRow( issue: OverviewIssue, run: OverviewRunData | null, state: ExplorerAutofixState | null, - statePending: boolean + statePending: boolean, + statsPeriod: string ): OverviewRow { const eventCount = Number(issue.count); const {entries: analysis, headline} = buildAnalysis(run?.outputs); @@ -255,11 +266,13 @@ export function buildOverviewRow( project: issue.project, eventCount: Number.isFinite(eventCount) ? eventCount : 0, userCount: issue.userCount, - lastActivityAt: - state?.updated_at ?? - run?.lastTriggeredAt ?? - issue.seerAutofixLastTriggered ?? - issue.lastSeen, + statsPeriod, + lastActivityAt: mostRecentTimestamp( + state?.updated_at, + run?.lastTriggeredAt, + issue.seerAutofixLastTriggered, + issue.lastSeen + ), autofixRunStatus: deriveRunStatus(state), prMerged: (run?.pullRequests ?? []).some(pr => pr.status === 'merged'), isProcessing: state?.status === 'processing', diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 4dff75ae0f67..357f37f6aea6 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -744,6 +744,27 @@ describe('AutofixOverview', () => { ).not.toBeInTheDocument(); }); + it('surfaces a per-section error while other sections still load', async () => { + // Only the code-changes bucket fails; the others resolve as seeded. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({query: SECTION_QUERIES.code_changes_ready})], + statusCode: 500, + body: {detail: 'boom'}, + }); + + renderPage(); + + // The review bucket still renders its card… + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + // …while the failed section shows a retryable error inline. + expect(await screen.findByText('There was an error loading data.')).toBeInTheDocument(); + }); + it('renders an error state only when every section fails', async () => { // An unmatched mock is newest, so it answers all five section requests. MockApiClient.addMockResponse({ diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index fc63143e4d42..c6fc05d5d753 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -32,6 +32,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {DEFAULT_STATS_PERIOD, PERIOD_FILTER_OPTIONS} from './periods'; import {SectionIssueCard} from './sectionIssueCard'; import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './statusGroups'; import { @@ -48,13 +49,6 @@ const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ {value: 'events', label: t('Most events')}, ]; -const PERIOD_FILTER_OPTIONS: Array<{label: string; value: string}> = [ - {value: '', label: t('All time')}, - {value: '24h', label: t('Last 24 hours')}, - {value: '7d', label: t('Last 7 days')}, - {value: '30d', label: t('Last 30 days')}, -]; - export default function AutofixOverview() { const organization = useOrganization(); const location = useLocation(); @@ -64,7 +58,7 @@ export default function AutofixOverview() { // fully expanded, fetched by group id so it resolves even outside the // list's filters. const selectedId = decodeScalar(location.query.id); - const period = decodeScalar(location.query.period); + const period = decodeScalar(location.query.period) ?? DEFAULT_STATS_PERIOD; // Legacy ?sort=triage (and anything unknown) decodes to the default. const sort = decodeScalar(location.query.sort) === 'events' ? 'events' : 'activity'; @@ -77,13 +71,13 @@ export default function AutofixOverview() { enabled: pageFiltersReady && !selectedId, projects: selection.projects, sort: sort === 'events' ? 'freq' : 'date', - statsPeriod: period || '90d', + statsPeriod: period, }); const pinnedIssueQuery = useQuery({ ...apiOptions.as()('/organizations/$organizationIdOrSlug/issues/', { path: {organizationIdOrSlug: organization.slug}, - query: {group: [selectedId ?? ''], project: -1}, + query: {group: [selectedId ?? ''], project: -1, statsPeriod: period}, staleTime: 30_000, }), enabled: Boolean(selectedId), @@ -166,12 +160,14 @@ export default function AutofixOverview() { updateQuery({ period: - selected.value === '' ? undefined : String(selected.value), + selected.value === DEFAULT_STATS_PERIOD + ? undefined + : String(selected.value), }) } trigger={triggerProps => ( @@ -264,6 +260,7 @@ export default function AutofixOverview() { issue={issue} orgSlug={organization.slug} view="cards" + statsPeriod={period} defaultExpanded lazy={false} /> @@ -309,7 +306,9 @@ export default function AutofixOverview() { - {section.isPending ? ( + {section.isError ? ( + + ) : section.isPending ? ( ) : section.issues.length === 0 ? ( @@ -325,6 +324,7 @@ export default function AutofixOverview() { issue={issue} orgSlug={organization.slug} view={view} + statsPeriod={period} isLast={index === section.issues.length - 1} /> ))} diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 9b4780324e49..6143c8bb8839 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -26,6 +26,7 @@ import {ellipsize} from 'sentry/utils/string/ellipsize'; import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; import {ATTENTION_META, AttentionBadge, getAttentionReason} from './attentionBadge'; +import {periodWindowLabel} from './periods'; import {TriggerBadge} from './triggerBadge'; import type {OverviewRow, PatchStats} from './types'; @@ -184,11 +185,16 @@ export function IssueCard({ title={ row.userCount > 0 ? t( - '%s events and %s affected users in the last 90 days', + '%s events and %s affected users %s', row.eventCount.toLocaleString(), - row.userCount.toLocaleString() + row.userCount.toLocaleString(), + periodWindowLabel(row.statsPeriod) + ) + : t( + '%s events %s', + row.eventCount.toLocaleString(), + periodWindowLabel(row.statsPeriod) ) - : t('%s events in the last 90 days', row.eventCount.toLocaleString()) } size="sm" variant="muted" @@ -223,6 +229,12 @@ export function IssueCard({ {/* Contained like its Tag/button neighbors so the diff size doesn't read as floating text */} = [ + {value: '24h', label: t('Last 24 hours')}, + {value: '7d', label: t('Last 7 days')}, + {value: '30d', label: t('Last 30 days')}, + {value: '90d', label: t('Last 90 days')}, +]; + +export function periodWindowLabel(statsPeriod: string): string { + const option = + PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === statsPeriod) ?? + PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === DEFAULT_STATS_PERIOD)!; + return t('in the %s', option.label.toLowerCase()); +} diff --git a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx index 930bf9b10572..f937a9ac7dad 100644 --- a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx +++ b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx @@ -63,16 +63,18 @@ function HydratedCard({ issue, orgSlug, view, + statsPeriod, isLast, }: { issue: OverviewIssue; orgSlug: string; + statsPeriod: string; view: 'cards' | 'table'; defaultExpanded?: boolean; isLast?: boolean; }) { const {run, state, statePending} = useIssueCardContent(issue.id); - const row = buildOverviewRow(issue, run, state, statePending); + const row = buildOverviewRow(issue, run, state, statePending, statsPeriod); return view === 'cards' ? ( @@ -87,6 +89,7 @@ export function SectionIssueCard({ }: { issue: OverviewIssue; orgSlug: string; + statsPeriod: string; view: 'cards' | 'table'; defaultExpanded?: boolean; isLast?: boolean; diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 77ecda9351ba..b128bf1bad47 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -66,6 +66,9 @@ export interface OverviewRow { shortId: string; // Whether the per-issue autofix state request is still in flight. statePending: boolean; + // The stats period the event/user counts were fetched over; labels the + // count tooltip so it matches the active period filter. + statsPeriod: string; title: string; userCount: number; // Plain-language title from the run's root-cause answer (see runQuestions). From 6e7f15a197f5bed2f5d9be26e160b2d2b256a1d3 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 10:43:58 -0700 Subject: [PATCH 04/19] ref(seer): Clean up comments and naming in the overview Trim stale and overwrought comments across the overview (references to a removed progress ring, a nonexistent Diagnosis block, and running/errored groups that no longer exist), and move the buildAnalysis docstring onto the function it describes. Rename the client-internal analysis key reviewer_notes -> next_steps and the AutofixRunStatus bucket COMPLETED -> SETTLED to match what the UI actually represents (a run that has stopped, not necessarily one that finished). --- .../autofixIssuesDemo/useAutofixIssues.tsx | 23 ++++++++---------- .../seerWorkflows/overview/attentionBadge.tsx | 3 +-- .../overview/buildOverviewRows.ts | 20 ++++++++-------- .../views/seerWorkflows/overview/index.tsx | 23 +++++------------- .../seerWorkflows/overview/issueCard.tsx | 24 ++++++------------- .../seerWorkflows/overview/runQuestions.tsx | 2 +- .../seerWorkflows/overview/statusGroups.tsx | 11 ++++----- .../app/views/seerWorkflows/overview/types.ts | 7 +++--- 8 files changed, 43 insertions(+), 70 deletions(-) diff --git a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx index 7260ebb31b40..a67130f6f627 100644 --- a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx +++ b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx @@ -31,9 +31,9 @@ function withRequiredFilter(query: string): string { // ``group:[...]`` filter so we only fetch runs for the issues on the page. const RUNS_QUERY = 'type:explorer source:autofix'; -// Custom one-shot question asked about each run, sent to the endpoint as a -// repeatable `question` param (see organization_seer_runs.py). Edit this list -// to iterate on prompts without a backend change. Capped at 5 by the endpoint. +// Custom one-shot questions asked about each run, sent to the endpoint as a +// repeatable `question` param (see organization_seer_runs.py). Capped at 5 by +// the endpoint. const DEMO_QUESTIONS = [ 'What was the most important bit of evidence while investigating this issue?', 'What is the complexity of the fix? Count or estimate number of lines and files touched.', @@ -113,7 +113,7 @@ interface SeerRun { // Subset of the issue-stream group we render. interface Issue { - // Event count over the stats period. Endpoint sadly returns a string. + // Event count over the stats period; the endpoint returns it as a string. count: string; culprit: string; id: string; @@ -207,12 +207,9 @@ export function useAutofixIssues({ const groupIds = useMemo(() => issues.map(issue => issue.id), [issues]); const runsEnabled = groupIds.length > 0; - // 2. Enrich with each group's latest run, one request per group with - // per_page=1. A single batched group:[...] request looked cheaper, but the - // endpoint caps outputs-enabled pages at 10 runs ordered by recency — when - // the page's groups collectively have more runs than that, the oldest - // groups' runs fall off and their issues silently lose all answers. Per- - // group requests make coverage guaranteed with the same total one-shot work. + // One request per group (per_page=1): the endpoint caps outputs-enabled pages + // at 10 runs by recency, so a batched group:[...] request would silently drop + // the oldest groups' runs. const runResults = useQueries({ queries: groupIds.map(groupId => apiOptions.as()('/organizations/$organizationIdOrSlug/seer/runs/', { @@ -230,7 +227,7 @@ export function useAutofixIssues({ // 3. Fetch the full autofix state per group to derive its phase. This is one // request per issue on the page (bounded by PER_PAGE) -- the runs list // endpoint doesn't expose fine-grained phase, so we read each run's state - // ("the whole history") directly. useQueries preserves groupIds order. + // directly. useQueries preserves groupIds order. const autofixResults = useQueries({ queries: groupIds.map(groupId => apiOptions.as<{autofix: ExplorerAutofixState | null}>()( @@ -251,8 +248,8 @@ export function useAutofixIssues({ const enriched: AutofixIssue[] = issues.map((issue, i) => { const autofixResult = autofixResults[i]; const autofixState = autofixResult?.data?.autofix ?? null; - // The server already scopes each request to its group; the find() guards - // against mocks/responses carrying runs for other groups. + // The server scopes each request to its group; find() guards against a + // response carrying runs for another group. const runs = runResults[i]?.data; const run = runs?.find(candidate => candidate.groupId === issue.id) ?? null; return { diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx index 1934e4db162d..73c02c179236 100644 --- a/static/app/views/seerWorkflows/overview/attentionBadge.tsx +++ b/static/app/views/seerWorkflows/overview/attentionBadge.tsx @@ -68,8 +68,7 @@ export function getAttentionReason(row: OverviewRow): AttentionReason | null { return 'errored'; } const set = new Set(row.outcomes); - // A merged PR needs nothing further; when merge state is unknown (the - // deployed API doesn't return it yet) an opened PR reads as needing review. + // A merged PR needs nothing further; an opened PR reads as needing review. if (row.prMerged) { return null; } diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 3be181f818cd..b0566d10e38c 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -63,7 +63,7 @@ function deriveRunStatus(state: ExplorerAutofixState | null): AutofixRunStatus { case 'error': return 'ERROR'; default: - return 'COMPLETED'; + return 'SETTLED'; } } @@ -153,14 +153,6 @@ function extractPendingQuestion(state: ExplorerAutofixState | null): string | un return undefined; } -/** - * Join the run's answered questions back to their question configs. - * - * Matches primarily on the echoed question text (the endpoint echoes prompts - * back for user-supplied questions), falling back to position — answers are - * returned in question order. Empty answers mean "not applicable" (the prompts - * ask for an empty string) and are dropped. - */ // A headline longer than this means the model ignored the 14-word instruction // (or the pipe landed somewhere unintended) — treat it as a parse failure. const MAX_HEADLINE_LENGTH = 140; @@ -196,6 +188,14 @@ function normalizeBulletList(answer: string): string { .join('\n'); } +/** + * Join the run's answered questions back to their question configs. + * + * Matches primarily on the echoed question text (the endpoint echoes prompts + * back for user-supplied questions), falling back to position — answers are + * returned in question order. Empty answers mean "not applicable" (the prompts + * ask for an empty string) and are dropped. + */ function buildAnalysis(outputs: RunQuestion[] | undefined): { entries: RunAnalysisEntry[]; headline?: string; @@ -218,7 +218,7 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { const rootCause = parseRootCause(output.answer); headline = rootCause.headline; answer = rootCause.answer; - } else if (config.key === 'reviewer_notes') { + } else if (config.key === 'next_steps') { answer = normalizeBulletList(output.answer); } entries.push({ diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index c6fc05d5d753..19f0d27e79de 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -59,7 +59,7 @@ export default function AutofixOverview() { // list's filters. const selectedId = decodeScalar(location.query.id); const period = decodeScalar(location.query.period) ?? DEFAULT_STATS_PERIOD; - // Legacy ?sort=triage (and anything unknown) decodes to the default. + // Unknown or legacy sort values fall back to the default. const sort = decodeScalar(location.query.sort) === 'events' ? 'events' : 'activity'; // Project scoping comes from the canonical page-filters selection; the @@ -124,8 +124,6 @@ export default function AutofixOverview() { > - {/* The title lives in the app's slim top bar (Layout.Title fills the - TopBar slot); the description rides along as its info tip. */} {t('Autofix Overview')} - {/* xl because the link-variant button has no visual container — - a smaller gap reads as touching the segmented control */} - - size="xs" - value={view} - onChange={setView} - aria-label={t('View mode')} - > - } - aria-label={t('Card view')} - tooltip={t('Card view')} - /> - } - aria-label={t('Table view')} - tooltip={t('Table view')} - /> - - - - )} - {selectedId ? ( - pinnedIssueQuery.isError ? ( - - ) : pinnedIssueQuery.isPending ? ( - - ) : pinnedIssues.length === 0 ? ( - - - {t('Issue not found.')} - - - ) : ( - - {pinnedIssues.map(issue => ( - - ))} - - ) - ) : isError ? ( - - ) : firstLoad ? ( - - ) : allSectionsEmpty ? ( - - - {t('No completed autofix runs yet.')} - - + ) : ( - - {sections.map(section => { - const meta = STATUS_GROUP_META[section.key]; - return ( - toggleGroup(section.key, next)} - > - - - - } - skipWrapper - > - - - {meta.label} - {section.count ?? '…'} - - - - - {section.isError ? ( - - ) : section.isPending ? ( - - ) : section.issues.length === 0 ? ( - - - {t('No issues')} - - - ) : ( - - {section.issues.map((issue, index) => ( - - ))} - - )} - - - ); - })} - + + + setCollapsedGroups(allGroupsCollapsed ? [] : [...SECTION_ORDER]) + } + /> + + )} @@ -334,29 +114,6 @@ export default function AutofixOverview() { ); } -// Disclosure.Content hardcodes a padding-left to indent its panel under the -// title; the `> * + *` sibling selector drops it so the full-width cards line -// up flush with their group header. -const StatusGroup = styled(Disclosure)` - && > * + * { - padding-left: 0; - } -`; - -// Sticky group header; z-index isn't a layout-primitive prop so it lives here. -// Opaque background so cards scroll under it. -const GroupHeader = styled(Sticky)` - z-index: ${p => p.theme.zIndex.initial}; - width: 100%; - background: ${p => p.theme.tokens.background.secondary}; - border-radius: ${p => p.theme.radius.md}; - - &[data-stuck] { - border-radius: 0; - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; - } -`; - function NoAccess() { return ( diff --git a/static/app/views/seerWorkflows/overview/overviewFilters.tsx b/static/app/views/seerWorkflows/overview/overviewFilters.tsx new file mode 100644 index 000000000000..d98906433275 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/overviewFilters.tsx @@ -0,0 +1,105 @@ +import {Button} from '@sentry/scraps/button'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Flex} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {SegmentedControl} from '@sentry/scraps/segmentedControl'; + +import {PageFilterBar} from 'sentry/components/pageFilters/pageFilterBar'; +import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {IconChevron, IconGrid, IconTable} from 'sentry/icons'; +import {t} from 'sentry/locale'; + +import {DEFAULT_STATS_PERIOD, PERIOD_FILTER_OPTIONS} from './periods'; +import type {OverviewView, SortValue} from './types'; + +const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ + {value: 'activity', label: t('Recent activity')}, + {value: 'events', label: t('Most events')}, +]; + +export function OverviewFilters({ + allCollapsed, + onToggleAll, + onUpdateQuery, + onViewChange, + period, + sort, + view, +}: { + allCollapsed: boolean; + onToggleAll: () => void; + onUpdateQuery: (patch: Record) => void; + onViewChange: (view: OverviewView) => void; + period: string; + sort: SortValue; + view: OverviewView; +}) { + return ( + + + + + + + onUpdateQuery({ + period: + selected.value === DEFAULT_STATS_PERIOD + ? undefined + : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + onUpdateQuery({ + // Default sort keeps the URL clean. + sort: selected.value === 'activity' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + + + + size="xs" + value={view} + onChange={onViewChange} + aria-label={t('View mode')} + > + } + aria-label={t('Card view')} + tooltip={t('Card view')} + /> + } + aria-label={t('Table view')} + tooltip={t('Table view')} + /> + + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx new file mode 100644 index 000000000000..26c5e6e4431d --- /dev/null +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -0,0 +1,150 @@ +import styled from '@emotion/styled'; + +import {Badge} from '@sentry/scraps/badge'; +import {Disclosure} from '@sentry/scraps/disclosure'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {Sticky} from 'sentry/components/sticky'; +import {t} from 'sentry/locale'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {SectionIssueCard} from './sectionIssueCard'; +import { + STATUS_GROUP_META, + StatusGroupTooltip, + type StatusGroupKey, +} from './statusGroups'; +import type {OverviewView, SortValue} from './types'; +import {useAutofixSections} from './useAutofixSections'; + +export function SectionList({ + collapsedGroups, + enabled, + onToggleGroup, + period, + projects, + sort, + view, +}: { + collapsedGroups: StatusGroupKey[]; + enabled: boolean; + onToggleGroup: (groupKey: StatusGroupKey, expanded: boolean) => void; + period: string; + projects: number[]; + sort: SortValue; + view: OverviewView; +}) { + const organization = useOrganization(); + const {sections, isPending, isError, refetch} = useAutofixSections({ + enabled, + projects, + sort: sort === 'events' ? 'freq' : 'date', + statsPeriod: period, + }); + + const firstLoad = isPending && sections.every(section => section.isPending); + const allSectionsEmpty = sections.every( + section => !section.isPending && section.issues.length === 0 + ); + + if (isError) { + return ; + } + if (firstLoad) { + return ; + } + if (allSectionsEmpty) { + return ( + + + {t('No completed autofix runs yet.')} + + + ); + } + + return ( + + {sections.map(section => { + const meta = STATUS_GROUP_META[section.key]; + return ( + onToggleGroup(section.key, next)} + > + + + + } + skipWrapper + > + + + {meta.label} + {section.count ?? '…'} + + + + + {section.isError ? ( + + ) : section.isPending ? ( + + ) : section.issues.length === 0 ? ( + + + {t('No issues')} + + + ) : ( + + {section.issues.map((issue, index) => ( + + ))} + + )} + + + ); + })} + + ); +} + +// Disclosure.Content hardcodes a padding-left to indent its panel under the +// title; the `> * + *` sibling selector drops it so the full-width cards line +// up flush with their group header. +const StatusGroup = styled(Disclosure)` + && > * + * { + padding-left: 0; + } +`; + +// Sticky group header; z-index isn't a layout-primitive prop so it lives here. +// Opaque background so cards scroll under it. +const GroupHeader = styled(Sticky)` + z-index: ${p => p.theme.zIndex.initial}; + width: 100%; + background: ${p => p.theme.tokens.background.secondary}; + border-radius: ${p => p.theme.radius.md}; + + &[data-stuck] { + border-radius: 0; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + } +`; diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index b3c502c94ab2..9a635d961af4 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -42,6 +42,10 @@ export const PIPELINE: PipelineStage[] = [ export const SECTION_ORDER: AutofixStateKey[] = PIPELINE.map(stage => stage.key); +// Toolbar controls, persisted in the URL (sort) and localStorage (view). +export type SortValue = 'activity' | 'events'; +export type OverviewView = 'cards' | 'table'; + // Live run status, mirrored straight from ExplorerAutofixState.status. Drives // the transient card overlays (Running / Retry / Add context), never the // section-driven primary action. From 42075541252636872cbc9489afc4bcf4907cf9c5 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 11:38:44 -0700 Subject: [PATCH 10/19] feat(seer): Cap section rendering with show-more Each rendered overview card mounts two live enrichment queries, and sections fetch up to 100 issues; with LazyRender never unmounting once visible, a fully-scrolled page accumulated up to ~1000 live subscriptions all refetching on window focus. Cap each section at 25 hydrated rows via Collapsible and reveal the rest on demand. Unrevealed cards stay unmounted (Collapsible slices before rendering), so their queries never start until requested. The section header badge keeps reporting the full X-Hits total. --- .../seerWorkflows/overview/index.spec.tsx | 29 +++++++++++ .../seerWorkflows/overview/sectionList.tsx | 49 ++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 1daba0691296..63e3fbd6e07c 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -497,6 +497,35 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); + it('caps a section at the render limit and reveals the rest on demand', async () => { + // More issues than the per-section render cap: only the cap hydrates, the + // rest stay unmounted behind a Show-more affordance while the header badge + // still reports the full X-Hits total. + const many = Array.from({length: 30}, (_, index) => + GroupFixture({ + id: `${100 + index}`, + shortId: `PROJ-${100 + index}`, + title: `Capped issue ${index}`, + }) + ); + many.forEach(group => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/`, + body: {autofix: null}, + }); + }); + mockSection(SECTION_QUERIES.review_pr, {body: many, hits: '30'}); + + renderPage(); + + await screen.findByRole('button', {name: 'Awaiting your review 30'}); + expect(screen.getAllByRole('link', {name: /Capped issue/})).toHaveLength(25); + + await userEvent.click(screen.getByRole('button', {name: 'Show 5 more issues'})); + + expect(screen.getAllByRole('link', {name: /Capped issue/})).toHaveLength(30); + }); + it('surfaces the blocking question when a run awaits user input', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx index 26c5e6e4431d..dae77aeb12b9 100644 --- a/static/app/views/seerWorkflows/overview/sectionList.tsx +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -1,15 +1,17 @@ import styled from '@emotion/styled'; import {Badge} from '@sentry/scraps/badge'; +import {Button} from '@sentry/scraps/button'; import {Disclosure} from '@sentry/scraps/disclosure'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; +import {Collapsible} from 'sentry/components/collapsible'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Sticky} from 'sentry/components/sticky'; -import {t} from 'sentry/locale'; +import {t, tn} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SectionIssueCard} from './sectionIssueCard'; @@ -21,6 +23,11 @@ import { import type {OverviewView, SortValue} from './types'; import {useAutofixSections} from './useAutofixSections'; +// Each rendered card mounts two live enrichment queries, so cap how many hydrate +// per section and reveal the rest on demand — the header badge keeps the true +// total. Unrevealed cards stay unmounted (Collapsible slices before rendering). +const SECTION_RENDER_CAP = 25; + export function SectionList({ collapsedGroups, enabled, @@ -105,17 +112,35 @@ export function SectionList({ ) : ( - {section.issues.map((issue, index) => ( - - ))} + ( + + )} + collapseButton={({onCollapse}) => ( + + )} + > + {section.issues.map((issue, index) => ( + + ))} + )} From 0a3a7da19f17e688035ebfaf68038304940b8e03 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 11:49:59 -0700 Subject: [PATCH 11/19] test(seer): Rebuild overview spec on fixture builders, cover the derivations The overview spec re-inlined near-identical run and autofix-state blocks across many tests, some carrying fields the code never reads. Introduce makeRun / makeAutofixState / makeBlock / makeOutput builders so each test states only the dimension it varies, and drop mock fields nothing reads (run type/dateCreated, output key, PR mergedAt, block id/timestamp, and the unread repo_pr_states fields). Every prior assertion is preserved. Add direct coverage for the pure derivation logic, which the component tests only exercised indirectly: - buildOverviewRows.spec: parseRootCause (pipe split, no-pipe, over-length headline, empty parts, emphasis strip), normalizeBulletList (inline bullet rewrite, no-space bullet, no-op), extractPendingQuestion (nested question, flat key fallbacks, blank/missing guards, status guard), and a table-driven deriveSectionKey precedence check down to the floor. - cardAction.spec: deriveCardAction per stage key (review_pr carries the PR), and IssuePrimaryAction's pending placeholder plus the transient runStatus overlays that never reclassify the section anchor. - triggerBadge.spec: mapRunSourceToTrigger mapping and the raw-source fallback badge. Export parseRootCause / normalizeBulletList / extractPendingQuestion so they can be unit-tested next to their module. --- .../overview/buildOverviewRows.spec.ts | 184 +++++++++++++ .../overview/buildOverviewRows.ts | 8 +- .../overview/cardAction.spec.tsx | 105 ++++++++ .../seerWorkflows/overview/index.spec.tsx | 241 +++++++----------- .../overview/triggerBadge.spec.tsx | 40 +++ 5 files changed, 423 insertions(+), 155 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts create mode 100644 static/app/views/seerWorkflows/overview/cardAction.spec.tsx create mode 100644 static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts new file mode 100644 index 000000000000..38acd0527330 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts @@ -0,0 +1,184 @@ +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; + +import { + deriveSectionKey, + extractPendingQuestion, + normalizeBulletList, + parseRootCause, +} from 'sentry/views/seerWorkflows/overview/buildOverviewRows'; +import type {SeerRun} from 'sentry/views/seerWorkflows/overview/types'; + +function makeBlock(step: string) { + return {message: {role: 'assistant', content: step, metadata: {step}}}; +} + +function makeState(overrides: Record = {}): ExplorerAutofixState { + return { + status: 'completed', + blocks: [], + ...overrides, + } as unknown as ExplorerAutofixState; +} + +function makeRun(overrides: Partial = {}): SeerRun { + return { + id: 'run-1', + groupId: '2', + source: 'autofix', + lastTriggeredAt: '2026-07-14T09:00:00Z', + ...overrides, + }; +} + +describe('parseRootCause', () => { + it('splits a headline from the root cause on the first pipe', () => { + expect(parseRootCause('Cart total is null|Commit c5bb895 removed the guard.')).toEqual( + { + headline: 'Cart total is null', + answer: 'Commit c5bb895 removed the guard.', + } + ); + }); + + it('splits only on the first pipe, keeping later pipes in the body', () => { + expect(parseRootCause('Headline|body a | body b')).toEqual({ + headline: 'Headline', + answer: 'body a | body b', + }); + }); + + it('returns the whole answer unchanged when there is no pipe', () => { + expect(parseRootCause('No delimiter here')).toEqual({answer: 'No delimiter here'}); + }); + + it('strips wrapping emphasis and quote characters from the headline', () => { + expect(parseRootCause('**"Broken cart"**|because reasons')).toEqual({ + headline: 'Broken cart', + answer: 'because reasons', + }); + }); + + it('treats a headline past the max length as a parse failure', () => { + const answer = `${'word '.repeat(40)}|the cause`; + expect(parseRootCause(answer)).toEqual({answer}); + }); + + it('treats an empty headline or empty body as a parse failure', () => { + expect(parseRootCause('|only a body')).toEqual({answer: '|only a body'}); + expect(parseRootCause('only a headline|')).toEqual({answer: 'only a headline|'}); + }); +}); + +describe('normalizeBulletList', () => { + it('rewrites inline bullets into their own markdown list lines', () => { + expect(normalizeBulletList('Do this: •First •Second •Third')).toBe( + 'Do this:\n- First\n- Second\n- Third' + ); + }); + + it('handles a bullet with no trailing space', () => { + expect(normalizeBulletList('Intro •Item')).toBe('Intro\n- Item'); + }); + + it('leaves input without a bullet untouched', () => { + expect(normalizeBulletList('A single next step.')).toBe('A single next step.'); + }); +}); + +describe('extractPendingQuestion', () => { + it('returns nothing when the run is not awaiting user input', () => { + expect(extractPendingQuestion(makeState({status: 'completed'}))).toBeUndefined(); + expect(extractPendingQuestion(null)).toBeUndefined(); + }); + + it('reads the canonical questions[0].question shape', () => { + const state = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {questions: [{question: 'Which env?'}]}}, + }); + expect(extractPendingQuestion(state)).toBe('Which env?'); + }); + + it('falls back to the flat question / text / message keys in order', () => { + for (const key of ['question', 'text', 'message']) { + const state = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {[key]: `via ${key}`}}, + }); + expect(extractPendingQuestion(state)).toBe(`via ${key}`); + } + }); + + it('ignores blank or missing payloads', () => { + const blankNested = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {questions: [{question: ' '}]}}, + }); + expect(extractPendingQuestion(blankNested)).toBeUndefined(); + + const blankFlat = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {question: ' '}}, + }); + expect(extractPendingQuestion(blankFlat)).toBeUndefined(); + + const noPayload = makeState({status: 'awaiting_user_input'}); + expect(extractPendingQuestion(noPayload)).toBeUndefined(); + }); +}); + +describe('deriveSectionKey', () => { + const cases: Array<{ + expected: string; + name: string; + run: SeerRun | null; + state: ExplorerAutofixState | null; + }> = [ + { + name: 'a merged PR beats every reached step', + run: makeRun({pullRequests: [{status: 'merged'}]}), + state: makeState({ + blocks: [makeBlock('code_changes')], + repo_pr_states: {r: {pr_creation_status: 'completed'}}, + }), + expected: 'merged', + }, + { + name: 'a created PR beats code changes', + run: makeRun(), + state: makeState({ + blocks: [makeBlock('code_changes')], + repo_pr_states: {r: {pr_creation_status: 'completed'}}, + }), + expected: 'review_pr', + }, + { + name: 'code changes beat a solution', + run: makeRun(), + state: makeState({blocks: [makeBlock('solution'), makeBlock('code_changes')]}), + expected: 'code_changes_ready', + }, + { + name: 'a solution beats the floor', + run: makeRun(), + state: makeState({blocks: [makeBlock('root_cause'), makeBlock('solution')]}), + expected: 'solution_ready', + }, + { + name: 'a diagnosis-only run falls to needs_investigation', + run: makeRun(), + state: makeState({blocks: [makeBlock('root_cause')]}), + expected: 'needs_investigation', + }, + { + name: 'a null run and state fall to needs_investigation', + run: null, + state: null, + expected: 'needs_investigation', + }, + ]; + + it.each(cases)('$name', ({run, state, expected}) => { + expect(deriveSectionKey(run, state)).toBe(expected); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 7efb3a529049..24a11eeb6ba1 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -114,7 +114,9 @@ function extractPr( // The pending-input payload is untyped (Record). The canonical // ask_user_question shape is {questions: [{question, options}]} (see // usePendingUserInput's AskUserQuestionData); fall back to a flat key otherwise. -function extractPendingQuestion(state: ExplorerAutofixState | null): string | undefined { +export function extractPendingQuestion( + state: ExplorerAutofixState | null +): string | undefined { if (state?.status !== 'awaiting_user_input') { return undefined; } @@ -140,7 +142,7 @@ const MAX_HEADLINE_LENGTH = 140; // The root_cause prompt asks for "headline|root cause". Split on the first // pipe; strip stray emphasis/quote characters the model might wrap it in. -function parseRootCause(answer: string): {answer: string; headline?: string} { +export function parseRootCause(answer: string): {answer: string; headline?: string} { const pipeIndex = answer.indexOf('|'); if (pipeIndex === -1) { return {answer}; @@ -159,7 +161,7 @@ function parseRootCause(answer: string): {answer: string; headline?: string} { // The model sometimes emits inline "•" bullets run together in one paragraph; // markdown only renders a list when each item is its own "- " line. The bullet // may be followed by no space ("•Item"), so the trailing \s is optional. -function normalizeBulletList(answer: string): string { +export function normalizeBulletList(answer: string): string { if (!answer.includes('•')) { return answer; } diff --git a/static/app/views/seerWorkflows/overview/cardAction.spec.tsx b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx new file mode 100644 index 000000000000..a32d7010abf8 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx @@ -0,0 +1,105 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import { + deriveCardAction, + IssuePrimaryAction, +} from 'sentry/views/seerWorkflows/overview/cardAction'; +import type { + AutofixStateKey, + CardAction, + OverviewRow, + RunStatus, +} from 'sentry/views/seerWorkflows/overview/types'; + +function makeRow(overrides: Partial = {}): OverviewRow { + return { + analysis: [], + eventCount: 1, + id: '2', + lastActivityAt: '2026-07-14T10:00:00Z', + level: 'error', + project: {slug: 'proj'}, + runStatus: null, + shortId: 'PROJ-1', + statePending: false, + statsPeriod: '90d', + title: 'Boom', + userCount: 0, + ...overrides, + }; +} + +const runUrl = {pathname: '/organizations/org-slug/issues/2/', query: {seerDrawer: 'true'}}; + +describe('deriveCardAction', () => { + it.each([ + 'code_changes_ready', + 'solution_ready', + 'needs_investigation', + 'merged', + ] as AutofixStateKey[])('maps the %s section to its own action', sectionKey => { + expect(deriveCardAction(sectionKey, makeRow())).toEqual({type: sectionKey}); + }); + + it('carries the linked PR on the review_pr action', () => { + const action = deriveCardAction( + 'review_pr', + makeRow({prUrl: 'https://github.com/o/r/pull/9', prNumber: 9}) + ); + expect(action).toEqual({ + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }); + }); +}); + +describe('IssuePrimaryAction', () => { + function renderAction(action: CardAction, row: OverviewRow) { + return render(); + } + + it('renders a placeholder while the run state is pending', () => { + renderAction({type: 'needs_investigation'}, makeRow({statePending: true})); + + expect(screen.getByText('…')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it.each([ + {runStatus: 'processing' as RunStatus, overlay: 'Running'}, + {runStatus: 'error' as RunStatus, overlay: 'Retry'}, + {runStatus: 'awaiting_user_input' as RunStatus, overlay: 'Add context'}, + ])( + 'paints the $overlay overlay over the section action when the run is $runStatus', + ({runStatus, overlay}) => { + // A review_pr card (section anchor) whose live run is mid-flight: the + // overlay shows and the Review PR anchor is hidden, but the section is + // not reclassified — see the anchor assertion below. + const action: CardAction = { + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }; + renderAction(action, makeRow({runStatus})); + + expect(screen.getByText(overlay)).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); + } + ); + + it('shows the section-driven Review PR anchor once no overlay applies', () => { + const action: CardAction = { + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }; + renderAction(action, makeRow({runStatus: 'completed'})); + + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + 'https://github.com/o/r/pull/9' + ); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 63e3fbd6e07c..f1864692370a 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -36,93 +36,78 @@ describe('AutofixOverview', () => { userCount: 5, }); - // A run that reached every stage and opened a PR. - const autofixState = { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, - { - id: 'b2', - timestamp: '2026-07-14T09:10:00Z', - message: { - role: 'assistant', - content: 'plan', - metadata: {step: 'solution'}, - }, - }, - { - id: 'b3', - timestamp: '2026-07-14T09:20:00Z', - message: { - role: 'assistant', - content: 'code', - metadata: {step: 'code_changes'}, - }, - merged_file_patches: [ - { - repo_name: 'getsentry/sentry', - diff: '--- a/src/cart.py\n+++ b/src/cart.py', - patch: { - path: 'src/cart.py', - source_file: 'src/cart.py', - target_file: 'src/cart.py', - type: 'M', - added: 42, - removed: 7, - hunks: [], - }, - }, - ], - }, - ], - repo_pr_states: { - 'getsentry/sentry': { - repo_name: 'getsentry/sentry', - branch_name: 'fix/cart', - commit_sha: null, - pr_creation_error: null, - pr_creation_status: 'completed', - pr_id: null, - pr_number: 123, - pr_url: 'https://github.com/getsentry/sentry/pull/123', - title: 'Fix nil cart', - }, - }, - }; + // One answered run question keyed to its prompt (the endpoint echoes the + // prompt back), so buildAnalysis joins it to its question config by text. + function makeOutput(questionIndex: number, answer: string) { + return {question: RUN_QUESTIONS[questionIndex]!.prompt, answer}; + } + + // A pipeline block. Only `message.metadata.step` (section bucketing), + // `message.role`/`content` (section completion), and `merged_file_patches` + // (the diff artifact) are read, so the builder carries just those. + function makeBlock(step: string, overrides: Record = {}) { + return {message: {role: 'assistant', content: step, metadata: {step}}, ...overrides}; + } // The per-card runs payload: a night_shift-triggered run whose one-shot - // answers become the card's Root cause / Proposed fix prose. - const defaultRun = { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'night_shift', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_0', - question: RUN_QUESTIONS[0]!.prompt, - answer: - 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.', - }, - { - key: 'user_1', - question: RUN_QUESTIONS[1]!.prompt, - answer: 'Restores the Authorization header as a fallback.', + // answers become the card's Root cause / Proposed fix prose. Tests spread + // overrides to vary source, outputs, or linked PRs. + function makeRun(overrides: Record = {}) { + return { + id: 'run-1', + groupId: '2', + source: 'night_shift', + lastTriggeredAt: '2026-07-14T09:00:00Z', + outputs: [ + makeOutput( + 0, + 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.' + ), + makeOutput(1, 'Restores the Authorization header as a fallback.'), + ], + ...overrides, + }; + } + + // A run that reached every stage and opened a PR. Tests spread overrides to + // vary status, blocks, the pending-input payload, or the PR states. + function makeAutofixState(overrides: Record = {}) { + return { + run_id: 1, + status: 'completed', + updated_at: '2026-07-14T10:00:00Z', + blocks: [ + makeBlock('root_cause'), + makeBlock('solution'), + makeBlock('code_changes', { + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 42, + removed: 7, + hunks: [], + }, + }, + ], + }), + ], + repo_pr_states: { + 'getsentry/sentry': { + repo_name: 'getsentry/sentry', + pr_creation_status: 'completed', + pr_number: 123, + pr_url: 'https://github.com/getsentry/sentry/pull/123', + }, }, - ], - }; + ...overrides, + }; + } // The repo-wide IntersectionObserver mock (tests/js/setup.ts) is a no-op // whose observe() never fires its callback, so LazyRender content would @@ -192,11 +177,11 @@ describe('AutofixOverview', () => { // card as in view, so these fire once per rendered card. MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, - body: [defaultRun], + body: [makeRun()], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: {autofix: autofixState}, + body: {autofix: makeAutofixState()}, }); }); @@ -362,21 +347,10 @@ describe('AutofixOverview', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', + makeRun({ source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_2', - question: RUN_QUESTIONS[2]!.prompt, - answer: 'Decide whether to relax the constraint.', - }, - ], - }, + outputs: [makeOutput(2, 'Decide whether to relax the constraint.')], + }), ], }); @@ -416,16 +390,11 @@ describe('AutofixOverview', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', + makeRun({ source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - pullRequests: [{status: 'merged', mergedAt: '2026-07-15T09:00:00Z'}], + pullRequests: [{status: 'merged'}], outputs: [], - }, + }), ], }); @@ -530,31 +499,18 @@ describe('AutofixOverview', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, body: { - autofix: { - run_id: 1, + autofix: makeAutofixState({ status: 'awaiting_user_input', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, - ], + blocks: [makeBlock('root_cause')], // Canonical ask_user_question shape: the text is nested under // questions[0].question, not a flat key. pending_user_input: { - id: 'input-1', input_type: 'ask_user_question', data: { questions: [{question: 'Which environment should I target?', options: []}], }, }, - }, + }), }, }); @@ -595,19 +551,9 @@ describe('AutofixOverview', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, body: { - autofix: { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', + autofix: makeAutofixState({ blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'code', - metadata: {step: 'code_changes'}, - }, + makeBlock('code_changes', { merged_file_patches: [ { repo_name: 'getsentry/sentry', @@ -661,9 +607,10 @@ describe('AutofixOverview', () => { }, }, ], - }, + }), ], - }, + repo_pr_states: {}, + }), }, }); @@ -690,19 +637,9 @@ describe('AutofixOverview', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, body: { - autofix: { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', + autofix: makeAutofixState({ blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'code', - metadata: {step: 'code_changes'}, - }, + makeBlock('code_changes', { merged_file_patches: [ { repo_name: 'getsentry/sentry', @@ -735,9 +672,9 @@ describe('AutofixOverview', () => { }, }, ], - }, + }), ], - }, + }), }, }); diff --git a/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx b/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx new file mode 100644 index 000000000000..2e7a49dd4178 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx @@ -0,0 +1,40 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import { + mapRunSourceToTrigger, + TriggerBadge, +} from 'sentry/views/seerWorkflows/overview/triggerBadge'; + +describe('mapRunSourceToTrigger', () => { + it.each(['autofix', 'slack_thread', 'chat'])('maps %s to the manual trigger', source => { + expect(mapRunSourceToTrigger(source)).toBe('manual'); + }); + + it('maps night_shift to the workflow trigger', () => { + expect(mapRunSourceToTrigger('night_shift')).toBe('night_shift'); + }); + + it.each(['bug_fixer', 'dashboard_generate', null])( + 'leaves an unmapped source (%s) unclassified', + source => { + expect(mapRunSourceToTrigger(source)).toBeNull(); + } + ); +}); + +describe('TriggerBadge', () => { + it('renders the mapped trigger label', () => { + render(); + expect(screen.getByText('Workflow')).toBeInTheDocument(); + }); + + it('falls back to the raw source verbatim when the trigger is unmapped', () => { + render(); + expect(screen.getByText('bug_fixer')).toBeInTheDocument(); + }); + + it('renders nothing when neither a trigger nor a raw source is known', () => { + const {container} = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); From 525a1dcc15113d011038fe70fa43896da1e3a99c Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 13:00:33 -0700 Subject: [PATCH 12/19] fix(seer): Harden overview derivations and period label i18n Make the overview derivations total and the period window label a whole translatable unit: - mostRecentTimestamp now compares candidates by parsed epoch time instead of lexicographically, so a higher-precision timestamp no longer loses to an earlier one that happens to sort later. - normalizeBulletList trims and filters segments before prefixing "- ", so consecutive or trailing bullets no longer leave blank list items. - periodWindowLabel returns a complete translated phrase per period rather than interpolating a lowercased fragment into another translation, and its default lookup is total (no non-null assertion). - Correct the lastActivityAt comment: it labels the card's updated TimeSince, it does not drive sorting or the period filter. --- .../overview/buildOverviewRows.ts | 15 ++++++++++++--- .../views/seerWorkflows/overview/periods.ts | 18 +++++++++++------- .../app/views/seerWorkflows/overview/types.ts | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 24a11eeb6ba1..933bf5cdf758 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -5,6 +5,7 @@ import { isCodeChangesArtifact, isCodeChangesSection, } from 'sentry/components/events/autofix/useExplorerAutofix'; + import {RUN_QUESTIONS} from './runQuestions'; import {mapRunSourceToTrigger} from './triggerBadge'; import { @@ -166,9 +167,11 @@ export function normalizeBulletList(answer: string): string { return answer; } const [head = '', ...items] = answer.split(/\s*•\s*/); - return [head.trim(), ...items.map(item => `- ${item.trim()}`)] + const bullets = items + .map(item => item.trim()) .filter(Boolean) - .join('\n'); + .map(item => `- ${item}`); + return [head.trim(), ...bullets].filter(Boolean).join('\n'); } /** @@ -215,9 +218,15 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { function mostRecentTimestamp(...candidates: Array): string { let latest = ''; + let latestTime = -Infinity; for (const candidate of candidates) { - if (candidate && candidate > latest) { + if (!candidate) { + continue; + } + const time = new Date(candidate).getTime(); + if (time > latestTime) { latest = candidate; + latestTime = time; } } return latest; diff --git a/static/app/views/seerWorkflows/overview/periods.ts b/static/app/views/seerWorkflows/overview/periods.ts index 5c97dcd6ba7d..a11ac616b72c 100644 --- a/static/app/views/seerWorkflows/overview/periods.ts +++ b/static/app/views/seerWorkflows/overview/periods.ts @@ -2,16 +2,20 @@ import {t} from 'sentry/locale'; export const DEFAULT_STATS_PERIOD = '90d'; -export const PERIOD_FILTER_OPTIONS: Array<{label: string; value: string}> = [ - {value: '24h', label: t('Last 24 hours')}, - {value: '7d', label: t('Last 7 days')}, - {value: '30d', label: t('Last 30 days')}, - {value: '90d', label: t('Last 90 days')}, +export const PERIOD_FILTER_OPTIONS: Array<{ + label: string; + value: string; + windowLabel: string; +}> = [ + {value: '24h', label: t('Last 24 hours'), windowLabel: t('in the last 24 hours')}, + {value: '7d', label: t('Last 7 days'), windowLabel: t('in the last 7 days')}, + {value: '30d', label: t('Last 30 days'), windowLabel: t('in the last 30 days')}, + {value: '90d', label: t('Last 90 days'), windowLabel: t('in the last 90 days')}, ]; export function periodWindowLabel(statsPeriod: string): string { const option = PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === statsPeriod) ?? - PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === DEFAULT_STATS_PERIOD)!; - return t('in the %s', option.label.toLowerCase()); + PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === DEFAULT_STATS_PERIOD); + return option?.windowLabel ?? ''; } diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 9a635d961af4..a79052f04324 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -144,7 +144,7 @@ export interface OverviewRow { eventCount: number; id: string; // Most recent activity on the run (state update, trigger, or issue-level - // last-trigger timestamp) - drives sorting and the period filter. + // last-trigger timestamp); labels the card's "updated" TimeSince. lastActivityAt: string; level: Level; project: {slug: string; platform?: PlatformKey}; From e00f37370313800a6368ad62e4aedd57a8206f23 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 13:05:03 -0700 Subject: [PATCH 13/19] test(seer): Close the derivation and fallback coverage gaps Cover the overview derivations and fallback branches that had no tests: - buildOverviewRows: mostRecentTimestamp numeric ordering and the all-nullish case, the coding_agents-only deriveSectionKey path, buildAnalysis positional fallback and empty-answer dropping, the normalizeBulletList empty-item cases, and the extractPatchInfo file/changed-line boundaries plus multi-repo path prefixing (the boundaries are pinned to the exported constants). - index: X-Hits body-length count fallback, SECTION_ORDER header ordering, the review_pr internal-link fallback when a run has no PR url, the focused-issue empty and error branches, and a section retry-refetch after an error. - periods: the per-period window phrase, the unknown-period default, and that the default period is present in the option list. Exports mostRecentTimestamp, buildAnalysis, extractPatchInfo, and the inline-diff limit constants so the derivations can be tested directly. --- .../overview/buildOverviewRows.spec.ts | 194 +++++++++++++++++- .../overview/buildOverviewRows.ts | 12 +- .../seerWorkflows/overview/index.spec.tsx | 99 ++++++++- .../seerWorkflows/overview/periods.spec.ts | 27 +++ 4 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/periods.spec.ts diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts index 38acd0527330..2c5ef3a25809 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts @@ -1,8 +1,12 @@ import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; - import { + buildAnalysis, deriveSectionKey, + extractPatchInfo, extractPendingQuestion, + INLINE_DIFF_MAX_CHANGED_LINES, + INLINE_DIFF_MAX_FILES, + mostRecentTimestamp, normalizeBulletList, parseRootCause, } from 'sentry/views/seerWorkflows/overview/buildOverviewRows'; @@ -12,6 +16,67 @@ function makeBlock(step: string) { return {message: {role: 'assistant', content: step, metadata: {step}}}; } +function makePatch({ + repo = 'getsentry/sentry', + path = 'src/cart.py', + added = 1, + removed = 0, + lines = 1, +}: { + added?: number; + lines?: number; + path?: string; + removed?: number; + repo?: string; +} = {}) { + return { + repo_name: repo, + diff: '--- a\n+++ b', + patch: { + path, + source_file: path, + target_file: path, + type: 'M', + added, + removed, + hunks: [ + { + section_header: '', + source_start: 1, + source_length: lines, + target_start: 1, + target_length: lines, + lines: Array.from({length: lines}, (_, index) => ({ + value: `line ${index}`, + line_type: '+', + source_line_no: null, + target_line_no: index + 1, + diff_line_no: index + 1, + })), + }, + ], + }, + }; +} + +function makeCodeChangesState( + patches: Array> +): ExplorerAutofixState { + return makeState({ + status: 'completed', + blocks: [ + { + message: { + role: 'assistant', + content: 'code_changes', + metadata: {step: 'code_changes'}, + }, + merged_file_patches: patches, + }, + ], + }); +} + function makeState(overrides: Record = {}): ExplorerAutofixState { return { status: 'completed', @@ -32,12 +97,12 @@ function makeRun(overrides: Partial = {}): SeerRun { describe('parseRootCause', () => { it('splits a headline from the root cause on the first pipe', () => { - expect(parseRootCause('Cart total is null|Commit c5bb895 removed the guard.')).toEqual( - { - headline: 'Cart total is null', - answer: 'Commit c5bb895 removed the guard.', - } - ); + expect( + parseRootCause('Cart total is null|Commit c5bb895 removed the guard.') + ).toEqual({ + headline: 'Cart total is null', + answer: 'Commit c5bb895 removed the guard.', + }); }); it('splits only on the first pipe, keeping later pipes in the body', () => { @@ -83,6 +148,115 @@ describe('normalizeBulletList', () => { it('leaves input without a bullet untouched', () => { expect(normalizeBulletList('A single next step.')).toBe('A single next step.'); }); + + it('drops empty items from consecutive bullets', () => { + expect(normalizeBulletList('A ••B')).toBe('A\n- B'); + }); + + it('drops a trailing empty bullet', () => { + expect(normalizeBulletList('Intro •First •')).toBe('Intro\n- First'); + }); +}); + +describe('mostRecentTimestamp', () => { + it('returns the numerically latest of mixed defined candidates', () => { + // The .500Z timestamp is later in time but sorts *before* the whole-second + // one lexicographically ('.' < 'Z'), so this pins the numeric comparison. + expect( + mostRecentTimestamp( + '2026-07-01T10:00:00Z', + undefined, + '2026-07-01T10:00:00.500Z', + null + ) + ).toBe('2026-07-01T10:00:00.500Z'); + }); + + it('returns an empty string when every candidate is nullish', () => { + expect(mostRecentTimestamp(null, undefined)).toBe(''); + }); +}); + +describe('buildAnalysis', () => { + it('maps an output with no question field to its positional config', () => { + const {entries, headline} = buildAnalysis([ + {key: '', answer: 'Cart total is null|The guard was removed.'}, + ]); + expect(headline).toBe('Cart total is null'); + expect(entries).toEqual([ + {key: 'root_cause', label: 'Root cause', answer: 'The guard was removed.'}, + ]); + }); + + it('falls back positionally when the question string matches nothing', () => { + const {entries} = buildAnalysis([ + {key: '', question: 'not a real prompt', answer: 'A headline|a cause'}, + {key: '', question: 'still not real', answer: 'Adds a guard.'}, + ]); + expect(entries.map(entry => entry.key)).toEqual(['root_cause', 'fix_summary']); + }); + + it('drops empty-string answers', () => { + const {entries} = buildAnalysis([ + {key: '', answer: 'A headline|a cause'}, + {key: '', answer: ''}, + {key: '', answer: 'Confirm the config value.'}, + ]); + expect(entries.map(entry => entry.key)).toEqual(['root_cause', 'next_steps']); + }); +}); + +describe('extractPatchInfo', () => { + it('renders inline at exactly the changed-line limit', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({added: INLINE_DIFF_MAX_CHANGED_LINES, removed: 0, lines: 3}), + ]) + ); + expect(patchStats?.added).toBe(INLINE_DIFF_MAX_CHANGED_LINES); + expect(inlinePatches).toHaveLength(1); + }); + + it('falls back to a pill one line over the changed-line limit', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({added: INLINE_DIFF_MAX_CHANGED_LINES + 1, removed: 0, lines: 3}), + ]) + ); + expect(patchStats?.added).toBe(INLINE_DIFF_MAX_CHANGED_LINES + 1); + expect(inlinePatches).toBeUndefined(); + }); + + it('renders inline at exactly the file limit', () => { + const patches = Array.from({length: INLINE_DIFF_MAX_FILES}, (_, index) => + makePatch({path: `src/file${index}.py`, added: 1, removed: 0, lines: 1}) + ); + const {inlinePatches} = extractPatchInfo(makeCodeChangesState(patches)); + expect(inlinePatches).toHaveLength(INLINE_DIFF_MAX_FILES); + }); + + it('falls back to a pill one file over the file limit', () => { + const patches = Array.from({length: INLINE_DIFF_MAX_FILES + 1}, (_, index) => + makePatch({path: `src/file${index}.py`, added: 1, removed: 0, lines: 1}) + ); + const {inlinePatches, patchStats} = extractPatchInfo(makeCodeChangesState(patches)); + expect(inlinePatches).toBeUndefined(); + expect(patchStats?.files).toBe(INLINE_DIFF_MAX_FILES + 1); + }); + + it('prefixes file paths with the repo when the diff spans repos', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({repo: 'getsentry/sentry', path: 'src/a.py', added: 1, lines: 1}), + makePatch({repo: 'getsentry/getsentry', path: 'src/b.py', added: 1, lines: 1}), + ]) + ); + expect(patchStats?.fileList.map(file => file.path).sort()).toEqual([ + 'getsentry/getsentry:src/b.py', + 'getsentry/sentry:src/a.py', + ]); + expect(inlinePatches?.every(patch => patch.repoName !== undefined)).toBe(true); + }); }); describe('extractPendingQuestion', () => { @@ -158,6 +332,12 @@ describe('deriveSectionKey', () => { state: makeState({blocks: [makeBlock('solution'), makeBlock('code_changes')]}), expected: 'code_changes_ready', }, + { + name: 'a coding-agents-only run reads as code changes ready', + run: makeRun(), + state: makeState({blocks: [makeBlock('coding_agents')]}), + expected: 'code_changes_ready', + }, { name: 'a solution beats the floor', run: makeRun(), diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 933bf5cdf758..4e9a9d501f05 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -52,11 +52,11 @@ export function deriveSectionKey( // A diff qualifies for the on-card differ only when it is genuinely small: // few files, few changed lines, and bounded hunk context so a fix with huge // surrounding context can't blow the card up. -const INLINE_DIFF_MAX_FILES = 2; -const INLINE_DIFF_MAX_CHANGED_LINES = 25; +export const INLINE_DIFF_MAX_FILES = 2; +export const INLINE_DIFF_MAX_CHANGED_LINES = 25; const INLINE_DIFF_MAX_RENDERED_LINES = 60; -function extractPatchInfo(state: ExplorerAutofixState | null): { +export function extractPatchInfo(state: ExplorerAutofixState | null): { inlinePatches?: OverviewRow['inlinePatches']; patchStats?: PatchStats; } { @@ -182,7 +182,7 @@ export function normalizeBulletList(answer: string): string { * returned in question order. Empty answers mean "not applicable" (the prompts * ask for an empty string) and are dropped. */ -function buildAnalysis(outputs: RunQuestion[] | undefined): { +export function buildAnalysis(outputs: RunQuestion[] | undefined): { entries: RunAnalysisEntry[]; headline?: string; } { @@ -216,7 +216,9 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { return {entries, headline}; } -function mostRecentTimestamp(...candidates: Array): string { +export function mostRecentTimestamp( + ...candidates: Array +): string { let latest = ''; let latestTime = -Infinity; for (const candidate of candidates) { diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index f1864692370a..9dcb98db6030 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -711,6 +711,15 @@ describe('AutofixOverview', () => { it('surfaces a per-section error while other sections still load', async () => { // Only the code-changes bucket fails; the others resolve as seeded. + const codeIssue = GroupFixture({ + id: '3', + shortId: 'PROJ-3', + title: 'Retry succeeded issue', + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/3/autofix/`, + body: {autofix: null}, + }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, match: [MockApiClient.matchQuery({query: SECTION_QUERIES.code_changes_ready})], @@ -727,7 +736,18 @@ describe('AutofixOverview', () => { }) ).toBeInTheDocument(); // …while the failed section shows a retryable error inline. - expect(await screen.findByText('There was an error loading data.')).toBeInTheDocument(); + expect( + await screen.findByText('There was an error loading data.') + ).toBeInTheDocument(); + + // Re-arm the section with a success response, then retry: its card + // appears, proving the LoadingError's onRetry refetches the section. + mockSection(SECTION_QUERIES.code_changes_ready, {body: [codeIssue], hits: '1'}); + await userEvent.click(screen.getByRole('button', {name: 'Retry'})); + + expect( + await screen.findByRole('link', {name: 'Retry succeeded issue'}) + ).toBeInTheDocument(); }); it('renders an error state only when every section fails', async () => { @@ -744,4 +764,81 @@ describe('AutofixOverview', () => { await screen.findByText('There was an error loading data.') ).toBeInTheDocument(); }); + + it('falls back to the body length for the count when X-Hits is absent', async () => { + // No X-Hits header, so the badge reports the returned body length. + mockSection(SECTION_QUERIES.review_pr, {body: [issue]}); + + renderPage(); + + expect( + await screen.findByRole('button', {name: 'Awaiting your review 1'}) + ).toBeInTheDocument(); + }); + + it('renders the section headers in pipeline (SECTION_ORDER) order', async () => { + renderPage(); + + await screen.findByRole('button', {name: /Awaiting your review/}); + + // Reordering the PIPELINE table reorders these headers and fails here. + const headers = [ + /Awaiting your review/, + /Code changes ready/, + /Ready to generate code/, + /Needs investigation/, + /Merged/, + ].map(name => screen.getByRole('button', {name})); + + for (let index = 0; index < headers.length - 1; index++) { + expect( + headers[index]!.compareDocumentPosition(headers[index + 1]!) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + } + }); + + it('falls back to the internal review link when a review_pr run has no PR url', async () => { + // Section is authoritative (review_pr), but the enrichment carries no PR + // url, so the primary action links back into the run, not out to GitHub. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: {autofix: makeAutofixState({repo_pr_states: {}})}, + }); + + renderPage(); + + const reviewButton = await screen.findByRole('button', {name: 'Review PR'}); + expect(reviewButton).toHaveAttribute( + 'href', + `/organizations/${organization.slug}/issues/2/?seerDrawer=true` + ); + }); + + it('shows a not-found message when the focused issue resolves empty', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({group: ['2']})], + body: [], + }); + + renderPage({id: '2'}); + + expect(await screen.findByText('Issue not found.')).toBeInTheDocument(); + }); + + it('shows an error state when the focused issue request fails', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({group: ['2']})], + statusCode: 500, + body: {detail: 'boom'}, + }); + + renderPage({id: '2'}); + + expect( + await screen.findByText('There was an error loading data.') + ).toBeInTheDocument(); + }); }); diff --git a/static/app/views/seerWorkflows/overview/periods.spec.ts b/static/app/views/seerWorkflows/overview/periods.spec.ts new file mode 100644 index 000000000000..31eb1f470917 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/periods.spec.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_STATS_PERIOD, + PERIOD_FILTER_OPTIONS, + periodWindowLabel, +} from 'sentry/views/seerWorkflows/overview/periods'; + +describe('periodWindowLabel', () => { + it('returns the window phrase for a known period', () => { + expect(periodWindowLabel('24h')).toBe('in the last 24 hours'); + expect(periodWindowLabel('7d')).toBe('in the last 7 days'); + }); + + it('falls back to the default window phrase for an unknown period', () => { + const defaultOption = PERIOD_FILTER_OPTIONS.find( + option => option.value === DEFAULT_STATS_PERIOD + ); + expect(periodWindowLabel('not-a-period')).toBe(defaultOption?.windowLabel); + }); +}); + +describe('PERIOD_FILTER_OPTIONS', () => { + it('includes the default stats period', () => { + expect( + PERIOD_FILTER_OPTIONS.some(option => option.value === DEFAULT_STATS_PERIOD) + ).toBe(true); + }); +}); From 0eb611b4eac895f9d1b7af7ded50b0e634eb5126 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 13:06:15 -0700 Subject: [PATCH 14/19] feat(seer): Cap autofix overview section counts at 100+ Sections fetch at most 100 issues but the badge showed the exact X-Hits total, overstating what scrolling could reveal. --- .../seerWorkflows/overview/index.spec.tsx | 12 ++++++++++ .../seerWorkflows/overview/sectionList.tsx | 22 +++++++++++-------- .../overview/useAutofixSections.tsx | 2 +- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 9dcb98db6030..9558133d737e 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -495,6 +495,18 @@ describe('AutofixOverview', () => { expect(screen.getAllByRole('link', {name: /Capped issue/})).toHaveLength(30); }); + it('caps the section count badge at 100+ when hits exceed the fetch limit', async () => { + // Only 100 issues are ever fetched per section, so an exact total above + // that would overstate what scrolling can reveal. + mockSection(SECTION_QUERIES.review_pr, {body: [issue], hits: '150'}); + + renderPage(); + + expect( + await screen.findByRole('button', {name: 'Awaiting your review 100+'}) + ).toBeInTheDocument(); + }); + it('surfaces the blocking question when a run awaits user input', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx index dae77aeb12b9..2c166f37ee18 100644 --- a/static/app/views/seerWorkflows/overview/sectionList.tsx +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -15,19 +15,23 @@ import {t, tn} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SectionIssueCard} from './sectionIssueCard'; -import { - STATUS_GROUP_META, - StatusGroupTooltip, - type StatusGroupKey, -} from './statusGroups'; +import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './statusGroups'; import type {OverviewView, SortValue} from './types'; -import {useAutofixSections} from './useAutofixSections'; +import {SECTION_LIMIT, useAutofixSections} from './useAutofixSections'; // Each rendered card mounts two live enrichment queries, so cap how many hydrate -// per section and reveal the rest on demand — the header badge keeps the true -// total. Unrevealed cards stay unmounted (Collapsible slices before rendering). +// per section and reveal the rest on demand — the header badge keeps the total, +// shown as "100+" beyond the fetch limit since only SECTION_LIMIT issues are +// fetched. Unrevealed cards stay unmounted (Collapsible slices before rendering). const SECTION_RENDER_CAP = 25; +function formatSectionCount(count: number | undefined) { + if (count === undefined) { + return '…'; + } + return count > SECTION_LIMIT ? `${SECTION_LIMIT}+` : count; +} + export function SectionList({ collapsedGroups, enabled, @@ -95,7 +99,7 @@ export function SectionList({ {meta.label} - {section.count ?? '…'} + {formatSectionCount(section.count)} diff --git a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx index 7d3a5ed9d8b9..ab94eb83c856 100644 --- a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx +++ b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx @@ -12,7 +12,7 @@ import { SECTION_ORDER, } from './types'; -const SECTION_LIMIT = 100; +export const SECTION_LIMIT = 100; export interface SectionResult { count: number | undefined; From 44fd27a58023d9e4f830e67474d3c9666d3e00cd Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 13:24:48 -0700 Subject: [PATCH 15/19] test(seer): Consolidate overview specs onto unit render coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the primary-action render matrix down to cardAction.spec, where it belongs, and drop the redundant full-page mounts that only re-proved it. cardAction.spec gains an it.each covering every non-overlay section action (merged, code_changes_ready, solution_ready — previously untested —, and needs_investigation) plus the internal review-link fallback for a review_pr card with no PR url. index.spec folds the header-ordering loop, the dropped-filter negatives, and the on-card analysis assertions into the two mounts that already render them, then deletes the standalone header-order, filter, thought-order, internal-review-link, Investigate, and merged-tag tests. Section-wiring stays proven by the section-authoritative merged test. Net: fewer full-page mounts, same behavioral coverage. --- .../overview/cardAction.spec.tsx | 31 +++- .../seerWorkflows/overview/index.spec.tsx | 143 +++++------------- 2 files changed, 71 insertions(+), 103 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/cardAction.spec.tsx b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx index a32d7010abf8..f2c338ad66d4 100644 --- a/static/app/views/seerWorkflows/overview/cardAction.spec.tsx +++ b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx @@ -29,7 +29,10 @@ function makeRow(overrides: Partial = {}): OverviewRow { }; } -const runUrl = {pathname: '/organizations/org-slug/issues/2/', query: {seerDrawer: 'true'}}; +const runUrl = { + pathname: '/organizations/org-slug/issues/2/', + query: {seerDrawer: 'true'}, +}; describe('deriveCardAction', () => { it.each([ @@ -102,4 +105,30 @@ describe('IssuePrimaryAction', () => { 'https://github.com/o/r/pull/9' ); }); + + it.each([ + {type: 'merged', label: 'Merged'}, + {type: 'code_changes_ready', label: 'Open PR'}, + {type: 'solution_ready', label: 'Generate code'}, + {type: 'needs_investigation', label: 'Investigate'}, + ] as Array<{label: string; type: Exclude}>)( + 'renders the $label action for a completed $type card', + ({type, label}) => { + renderAction({type}, makeRow({runStatus: 'completed'})); + + expect(screen.getByText(label)).toBeInTheDocument(); + } + ); + + it('falls back to the internal review link when a review_pr card has no PR url', () => { + renderAction( + {type: 'review_pr', prUrl: undefined, prNumber: undefined}, + makeRow({runStatus: 'completed'}) + ); + + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/2/?seerDrawer=true' + ); + }); }); diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 9558133d737e..adf1f6b7fe24 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -3,7 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -220,6 +220,22 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Merged 0'})).toBeInTheDocument(); + // The headers render in pipeline (SECTION_ORDER) order; reordering the + // PIPELINE table reorders these and fails here. + const orderedHeaders = [ + /Awaiting your review/, + /Code changes ready/, + /Ready to generate code/, + /Needs investigation/, + /Merged/, + ].map(name => screen.getByRole('button', {name})); + for (let index = 0; index < orderedHeaders.length - 1; index++) { + expect( + orderedHeaders[index]!.compareDocumentPosition(orderedHeaders[index + 1]!) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + } + // The review bucket's issue renders as a card between its own header and // the next section — server-bucketed, not classified client-side. const titleLink = await screen.findByRole('link', { @@ -240,13 +256,8 @@ describe('AutofixOverview', () => { // The four empty buckets each show their own empty text. expect(screen.getAllByText('No issues')).toHaveLength(4); - }); - - it('drops the outcome / needs-attention filters and pagination', async () => { - renderPage(); - - await screen.findByRole('button', {name: 'Awaiting your review 3'}); + // The legacy outcome / needs-attention filters and pagination are gone. expect(screen.queryByRole('button', {name: /Outcome/})).not.toBeInTheDocument(); expect( screen.queryByRole('button', {name: /Needs attention/}) @@ -254,7 +265,7 @@ describe('AutofixOverview', () => { expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument(); }); - it('renders a card with real run metadata', async () => { + it('renders a card with real run metadata and analysis in thought order', async () => { renderPage(); // The Seer headline replaces the raw issue title and links to the issue. @@ -291,6 +302,28 @@ describe('AutofixOverview', () => { // Issue impact numbers, abbreviated. expect(screen.getByText(/100 events/)).toBeInTheDocument(); + + // Both analysis sections render on the card face with no expansion needed… + const rootCause = screen.getByText('Root cause'); + const proposedFix = screen.getByText('Proposed fix'); + expect( + screen.getByText('Commit c5bb895 stopped sending the Authorization header.') + ).toBeVisible(); + expect( + screen.getByText('Restores the Authorization header as a fallback.') + ).toBeVisible(); + + // …in thought order: what broke, then what changed. + expect( + rootCause.compareDocumentPosition(proposedFix) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + // The timestamp is labeled as run activity. + expect(screen.getByText(/^updated/)).toBeInTheDocument(); + + // Identity sits in the tail: short id + exactly one level marker. + expect(screen.getByText('PROJ-1')).toBeVisible(); + expect(screen.getAllByText('Level: Warning')).toHaveLength(1); }); it('switches between card and table views', async () => { @@ -317,32 +350,6 @@ describe('AutofixOverview', () => { ); }); - it('renders the analysis on the card face in thought order', async () => { - renderPage(); - - // Both sections render with no expansion needed… - const rootCause = await screen.findByText('Root cause'); - const proposedFix = screen.getByText('Proposed fix'); - expect( - screen.getByText('Commit c5bb895 stopped sending the Authorization header.') - ).toBeVisible(); - expect( - screen.getByText('Restores the Authorization header as a fallback.') - ).toBeVisible(); - - // …in thought order: what broke, then what changed. - expect( - rootCause.compareDocumentPosition(proposedFix) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - - // The timestamp is labeled as run activity. - expect(screen.getByText(/^updated/)).toBeInTheDocument(); - - // Identity sits in the tail: short id + exactly one level marker. - expect(screen.getByText('PROJ-1')).toBeVisible(); - expect(screen.getAllByText('Level: Warning')).toHaveLength(1); - }); - it('leads with the root cause and a single next step when no code was drafted', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, @@ -368,21 +375,6 @@ describe('AutofixOverview', () => { expect(screen.queryByText('Proposed fix')).not.toBeInTheDocument(); }); - it('shows an Investigate action for cards in the needs-investigation section', async () => { - // Section is authoritative: a needs_investigation card offers Investigate - // (a Seer-drawer deep link), no matter what the enrichment reached. - mockSection(SECTION_QUERIES.review_pr, {body: []}); - mockSection(SECTION_QUERIES.needs_investigation, {body: [issue], hits: '1'}); - - renderPage(); - - const investigate = await screen.findByRole('button', {name: 'Investigate'}); - expect(investigate).toHaveAttribute( - 'href', - `/organizations/${organization.slug}/issues/2/?seerDrawer=true` - ); - }); - it('keeps the section review action even when the run enrichment looks merged', async () => { // A review_pr-section card whose enrichment carries a merged PR: the // section is the anchor, so the card still shows Review PR rather than a @@ -410,20 +402,6 @@ describe('AutofixOverview', () => { expect(screen.getAllByText('Merged')).toHaveLength(1); }); - it('shows a merged tag for cards in the merged section', async () => { - // The merged bucket's card renders the Merged status tag and no action. - mockSection(SECTION_QUERIES.review_pr, {body: []}); - mockSection(SECTION_QUERIES.merged, {body: [issue], hits: '1'}); - - renderPage(); - - // Two "Merged" once the card hydrates: the section header and the card tag. - await waitFor(() => { - expect(screen.getAllByText('Merged')).toHaveLength(2); - }); - expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); - }); - it('collapses sections individually and in bulk', async () => { renderPage(); @@ -788,45 +766,6 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); - it('renders the section headers in pipeline (SECTION_ORDER) order', async () => { - renderPage(); - - await screen.findByRole('button', {name: /Awaiting your review/}); - - // Reordering the PIPELINE table reorders these headers and fails here. - const headers = [ - /Awaiting your review/, - /Code changes ready/, - /Ready to generate code/, - /Needs investigation/, - /Merged/, - ].map(name => screen.getByRole('button', {name})); - - for (let index = 0; index < headers.length - 1; index++) { - expect( - headers[index]!.compareDocumentPosition(headers[index + 1]!) & - Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - } - }); - - it('falls back to the internal review link when a review_pr run has no PR url', async () => { - // Section is authoritative (review_pr), but the enrichment carries no PR - // url, so the primary action links back into the run, not out to GitHub. - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: {autofix: makeAutofixState({repo_pr_states: {}})}, - }); - - renderPage(); - - const reviewButton = await screen.findByRole('button', {name: 'Review PR'}); - expect(reviewButton).toHaveAttribute( - 'href', - `/organizations/${organization.slug}/issues/2/?seerDrawer=true` - ); - }); - it('shows a not-found message when the focused issue resolves empty', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, From 3458cd05188dfdc4bee19a9c8000e2a494c05370 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 13:31:43 -0700 Subject: [PATCH 16/19] knip --- static/app/views/seerWorkflows/overview/types.ts | 2 +- static/app/views/seerWorkflows/overview/useAutofixSections.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index a79052f04324..c5b22c6334bd 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -72,7 +72,7 @@ export interface RunQuestion { // A pull request linked to a run, serialized by PullRequestSerializer // src/sentry/api/serializers/models/pullrequest.py // `status` is 'open' | 'merged' | 'closed' | 'draft' | 'unknown'. -export interface RunPullRequest { +interface RunPullRequest { status: string | null; mergedAt?: string | null; } diff --git a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx index ab94eb83c856..b01d851d4ff3 100644 --- a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx +++ b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx @@ -14,7 +14,7 @@ import { export const SECTION_LIMIT = 100; -export interface SectionResult { +interface SectionResult { count: number | undefined; isError: boolean; isPending: boolean; From f9e4a043b299e00a1ac5a55bcdf2ad2fbacb83c5 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:33:36 +0000 Subject: [PATCH 17/19] :hammer_and_wrench: apply pre-commit fixes --- static/app/views/seerWorkflows/overview/cardAction.tsx | 8 +++++++- static/app/views/seerWorkflows/overview/issueCard.tsx | 10 +--------- .../app/views/seerWorkflows/overview/statusGroups.tsx | 8 +------- .../views/seerWorkflows/overview/triggerBadge.spec.tsx | 9 ++++++--- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/cardAction.tsx b/static/app/views/seerWorkflows/overview/cardAction.tsx index 1a8acf6db662..7946db403530 100644 --- a/static/app/views/seerWorkflows/overview/cardAction.tsx +++ b/static/app/views/seerWorkflows/overview/cardAction.tsx @@ -157,7 +157,13 @@ function ActionButton({actionKey, to}: {actionKey: ActionKey; to: LocationDescri ); } -function ReviewPrButton({prUrl, prNumber}: {prNumber: number | undefined; prUrl: string}) { +function ReviewPrButton({ + prUrl, + prNumber, +}: { + prNumber: number | undefined; + prUrl: string; +}) { const meta = ACTION_META.review_pr; return ( { - it.each(['autofix', 'slack_thread', 'chat'])('maps %s to the manual trigger', source => { - expect(mapRunSourceToTrigger(source)).toBe('manual'); - }); + it.each(['autofix', 'slack_thread', 'chat'])( + 'maps %s to the manual trigger', + source => { + expect(mapRunSourceToTrigger(source)).toBe('manual'); + } + ); it('maps night_shift to the workflow trigger', () => { expect(mapRunSourceToTrigger('night_shift')).toBe('night_shift'); From 861d2e7412d9bbf38bf1de7f4548e9cf4cf63ce6 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 14:08:33 -0700 Subject: [PATCH 18/19] fix(seer): Address overview review-bot findings Errored sections were counted as empty, so a page with some sections failing and the rest empty showed the global "no runs" box and never rendered the per-section load error. Exclude errored sections from the all-empty check so the section list and its inline error surface. Show a filter-aware empty message when a project is selected or a non-default period is active, so an empty result under filters reads as "no matches" instead of "no runs yet". Expose enrichmentPending (state or runs query pending) from the enrichment hook and drive the card's primary-action placeholder from it. In focus mode the section is derived from enrichment, and merge evidence lives only on the run, so deriving before the runs query settles could transiently misclassify a merged issue. Replace the isLast border prop with a :last-child CSS rule on the table rows' container, dropping the prop from the row and every call site. --- .../seerWorkflows/overview/issueCard.tsx | 4 +-- .../overview/sectionIssueCard.tsx | 14 +++-------- .../seerWorkflows/overview/sectionList.tsx | 25 ++++++++++++++----- .../overview/useIssueAutofixEnrichment.tsx | 2 ++ 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 36a52ad0f262..a46b098d735c 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -330,12 +330,10 @@ export function IssueCard({ * mode, while this row is optimized for scanning and taking the next action. */ export function IssueTableRow({ - isLast, orgSlug, row, sectionKey, }: { - isLast: boolean; orgSlug: string; row: OverviewRow; sectionKey: AutofixStateKey; @@ -351,7 +349,7 @@ export function IssueTableRow({ align="center" gap="lg" padding="md lg" - borderBottom={isLast ? undefined : 'primary'} + borderBottom="primary" > diff --git a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx index f09c6f977fa2..6930c7d56d75 100644 --- a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx +++ b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx @@ -16,20 +16,18 @@ function HydratedCard({ sectionKey, view, statsPeriod, - isLast, }: { issue: OverviewIssue; orgSlug: string; statsPeriod: string; view: 'cards' | 'table'; defaultExpanded?: boolean; - isLast?: boolean; // The server-bucketed section. Absent in focus mode, where the issues // endpoint omits issue.autofix_state, so we reconstruct it from enrichment. sectionKey?: AutofixStateKey; }) { - const {run, state, statePending} = useIssueAutofixEnrichment(issue.id); - const row = buildOverviewRow(issue, run, state, statePending, statsPeriod); + const {run, state, enrichmentPending} = useIssueAutofixEnrichment(issue.id); + const row = buildOverviewRow(issue, run, state, enrichmentPending, statsPeriod); const resolvedSectionKey = sectionKey ?? deriveSectionKey(run, state); return view === 'cards' ? ( @@ -40,12 +38,7 @@ function HydratedCard({ defaultExpanded={defaultExpanded} /> ) : ( - + ); } @@ -58,7 +51,6 @@ export function SectionIssueCard({ statsPeriod: string; view: 'cards' | 'table'; defaultExpanded?: boolean; - isLast?: boolean; lazy?: boolean; sectionKey?: AutofixStateKey; }) { diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx index 2c166f37ee18..50b0bb80650c 100644 --- a/static/app/views/seerWorkflows/overview/sectionList.tsx +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -14,6 +14,7 @@ import {Sticky} from 'sentry/components/sticky'; import {t, tn} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {DEFAULT_STATS_PERIOD} from './periods'; import {SectionIssueCard} from './sectionIssueCard'; import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './statusGroups'; import type {OverviewView, SortValue} from './types'; @@ -59,8 +60,9 @@ export function SectionList({ const firstLoad = isPending && sections.every(section => section.isPending); const allSectionsEmpty = sections.every( - section => !section.isPending && section.issues.length === 0 + section => !section.isPending && !section.isError && section.issues.length === 0 ); + const hasNonDefaultFilters = projects.length > 0 || period !== DEFAULT_STATS_PERIOD; if (isError) { return ; @@ -72,7 +74,9 @@ export function SectionList({ return ( - {t('No completed autofix runs yet.')} + {hasNonDefaultFilters + ? t('No autofix runs match your filters.') + : t('No completed autofix runs yet.')} ); @@ -115,7 +119,11 @@ export function SectionList({ ) : ( - + ( @@ -133,7 +141,7 @@ export function SectionList({ )} > - {section.issues.map((issue, index) => ( + {section.issues.map(issue => ( ))} - + )} @@ -155,6 +162,12 @@ export function SectionList({ ); } +const SectionRows = styled(Stack)` + &[data-view='table'] > *:last-child { + border-bottom: none; + } +`; + // Disclosure.Content hardcodes a padding-left to indent its panel under the // title; the `> * + *` sibling selector drops it so the full-width cards line // up flush with their group header. diff --git a/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx b/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx index 148af6d5f5d0..11271c70455d 100644 --- a/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx +++ b/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx @@ -8,6 +8,7 @@ import {RUN_QUESTION_PROMPTS} from './runQuestions'; import {QUERY_STALE_TIME, RUNS_QUERY, type SeerRun} from './types'; interface IssueAutofixEnrichment { + enrichmentPending: boolean; run: SeerRun | null; state: ExplorerAutofixState | null; statePending: boolean; @@ -43,5 +44,6 @@ export function useIssueAutofixEnrichment(issueId: string): IssueAutofixEnrichme run: runsQuery.data?.find(run => run.groupId === issueId) ?? null, state: stateQuery.data?.autofix ?? null, statePending: stateQuery.isPending, + enrichmentPending: stateQuery.isPending || runsQuery.isPending, }; } From 9b522b34ec40f7278d5d3556616672e1be5cafea Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 22 Jul 2026 14:13:46 -0700 Subject: [PATCH 19/19] ref(seer): Drop the section render cap, stabilize card heights Remove the 25-row render cap and the Collapsible show-more/less wrapper so sections render every fetched issue. The fetch limit and the 100+ count badge are unchanged. Because there is no longer a cap, height-stabilize lazy cards: while enrichment is pending the mounted card reserves at least the LazyRender placeholder height, so newly mounted cards can't collapse and cascade intersection mounts. The constraint releases once enrichment settles. Update tests: drop the show-more cap test in favor of asserting all 30 fetched issues render, cover the errored-plus-empty and filtered empty states, and select two projects so the plain empty-state test keeps an empty (no-filter) selection. --- .../seerWorkflows/overview/index.spec.tsx | 61 +++++++++++++++---- .../seerWorkflows/overview/issueCard.tsx | 13 +++- .../overview/sectionIssueCard.tsx | 11 +++- .../seerWorkflows/overview/sectionList.tsx | 48 ++++----------- 4 files changed, 83 insertions(+), 50 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index adf1f6b7fe24..fc6cc55d1dac 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -444,15 +444,12 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); - it('caps a section at the render limit and reveals the rest on demand', async () => { - // More issues than the per-section render cap: only the cap hydrates, the - // rest stay unmounted behind a Show-more affordance while the header badge - // still reports the full X-Hits total. + it('renders every fetched issue in a section', async () => { const many = Array.from({length: 30}, (_, index) => GroupFixture({ id: `${100 + index}`, shortId: `PROJ-${100 + index}`, - title: `Capped issue ${index}`, + title: `Bulk issue ${index}`, }) ); many.forEach(group => { @@ -466,11 +463,10 @@ describe('AutofixOverview', () => { renderPage(); await screen.findByRole('button', {name: 'Awaiting your review 30'}); - expect(screen.getAllByRole('link', {name: /Capped issue/})).toHaveLength(25); - - await userEvent.click(screen.getByRole('button', {name: 'Show 5 more issues'})); - - expect(screen.getAllByRole('link', {name: /Capped issue/})).toHaveLength(30); + expect(screen.getAllByRole('link', {name: /Bulk issue/})).toHaveLength(30); + expect( + screen.queryByRole('button', {name: /Show \d+ more issue/}) + ).not.toBeInTheDocument(); }); it('caps the section count badge at 100+ when hits exceed the fetch limit', async () => { @@ -687,7 +683,13 @@ describe('AutofixOverview', () => { }); it('shows the empty state when every section resolves empty', async () => { - // Override the seeded review bucket so all five sections are empty. + // Two projects so page filters default to "My Projects" (empty selection) + // rather than force-selecting the only project; the default period then + // makes this the no-filter case. + ProjectsStore.loadInitialData([ + ProjectFixture(), + ProjectFixture({id: '11', slug: 'project-two'}), + ]); mockSection(SECTION_QUERIES.review_pr, {body: []}); renderPage(); @@ -699,6 +701,43 @@ describe('AutofixOverview', () => { ).not.toBeInTheDocument(); }); + it('shows a filter-aware empty message when a non-default period is active', async () => { + // Two projects keep the selection empty, so the non-default period is the + // only active filter. + ProjectsStore.loadInitialData([ + ProjectFixture(), + ProjectFixture({id: '11', slug: 'project-two'}), + ]); + mockSection(SECTION_QUERIES.review_pr, {body: []}); + + renderPage({period: '24h'}); + + expect( + await screen.findByText('No autofix runs match your filters.') + ).toBeInTheDocument(); + expect(screen.queryByText('No completed autofix runs yet.')).not.toBeInTheDocument(); + }); + + it('surfaces per-section errors instead of the global empty state', async () => { + mockSection(SECTION_QUERIES.review_pr, {body: []}); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({query: SECTION_QUERIES.code_changes_ready})], + statusCode: 500, + body: {detail: 'boom'}, + }); + + renderPage(); + + expect( + await screen.findByText('There was an error loading data.') + ).toBeInTheDocument(); + expect(screen.queryByText('No completed autofix runs yet.')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', {name: /Awaiting your review/}) + ).toBeInTheDocument(); + }); + it('surfaces a per-section error while other sections still load', async () => { // Only the code-changes bucket fails; the others resolve as seeded. const codeIssue = GroupFixture({ diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index a46b098d735c..fd2981488bf8 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -125,6 +125,7 @@ export function IssueCard({ row, sectionKey, defaultExpanded = false, + minHeight, }: { orgSlug: string; row: OverviewRow; @@ -132,6 +133,7 @@ export function IssueCard({ // Open the inline diffs on mount — the overview's ?id= focus mode wants // the whole card readable at once. defaultExpanded?: boolean; + minHeight?: string; }) { const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; // Deep-link into the issue page with the Seer drawer already open, so the @@ -171,7 +173,13 @@ export function IssueCard({ const {eventCountLabel, userCountLabel} = issueCountLabels(row); return ( - + {/* Header: title over metadata subline, diff size pinned right */} @@ -333,10 +341,12 @@ export function IssueTableRow({ orgSlug, row, sectionKey, + minHeight, }: { orgSlug: string; row: OverviewRow; sectionKey: AutofixStateKey; + minHeight?: string; }) { const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; const runUrl = {pathname: issueUrl, query: {seerDrawer: 'true'}}; @@ -350,6 +360,7 @@ export function IssueTableRow({ gap="lg" padding="md lg" borderBottom="primary" + minHeight={minHeight} > diff --git a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx index 6930c7d56d75..16d8d3b8d568 100644 --- a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx +++ b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx @@ -29,6 +29,9 @@ function HydratedCard({ const {run, state, enrichmentPending} = useIssueAutofixEnrichment(issue.id); const row = buildOverviewRow(issue, run, state, enrichmentPending, statsPeriod); const resolvedSectionKey = sectionKey ?? deriveSectionKey(run, state); + const minHeight = enrichmentPending + ? `${view === 'cards' ? CARD_PLACEHOLDER_HEIGHT : TABLE_ROW_PLACEHOLDER_HEIGHT}px` + : undefined; return view === 'cards' ? ( ) : ( - + ); } diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx index 50b0bb80650c..7d210ace0224 100644 --- a/static/app/views/seerWorkflows/overview/sectionList.tsx +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -1,17 +1,15 @@ import styled from '@emotion/styled'; import {Badge} from '@sentry/scraps/badge'; -import {Button} from '@sentry/scraps/button'; import {Disclosure} from '@sentry/scraps/disclosure'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; -import {Collapsible} from 'sentry/components/collapsible'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Sticky} from 'sentry/components/sticky'; -import {t, tn} from 'sentry/locale'; +import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; import {DEFAULT_STATS_PERIOD} from './periods'; @@ -20,12 +18,6 @@ import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './stat import type {OverviewView, SortValue} from './types'; import {SECTION_LIMIT, useAutofixSections} from './useAutofixSections'; -// Each rendered card mounts two live enrichment queries, so cap how many hydrate -// per section and reveal the rest on demand — the header badge keeps the total, -// shown as "100+" beyond the fetch limit since only SECTION_LIMIT issues are -// fetched. Unrevealed cards stay unmounted (Collapsible slices before rendering). -const SECTION_RENDER_CAP = 25; - function formatSectionCount(count: number | undefined) { if (count === undefined) { return '…'; @@ -124,34 +116,16 @@ export function SectionList({ paddingTop="sm" data-view={view} > - ( - - )} - collapseButton={({onCollapse}) => ( - - )} - > - {section.issues.map(issue => ( - - ))} - + {section.issues.map(issue => ( + + ))} )}