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..cbd136317388 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -69,14 +69,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 +100,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,7 +226,6 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { entries.push({ key: config.key, label: config.label, - placement: config.placement, answer, }); }); @@ -223,7 +247,6 @@ 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 ?? @@ -237,7 +260,7 @@ function buildOverviewRow(issue: AutofixIssue): OverviewRow { trigger: mapRunSourceToTrigger(issue.run?.source ?? null), rawSource: issue.run?.source ?? null, analysis, - patchStats: extractPatchStats(state), + ...extractPatchInfo(state), pendingQuestion: extractPendingQuestion(state), ...extractPr(state), }; diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 82d47892743b..b73da625e4a4 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 {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'; @@ -18,7 +22,6 @@ describe('AutofixOverview', () => { title: 'TypeError in checkout cart', count: '100', userCount: 5, - seerFixabilityScore: 0.75, }); // A run that reached every stage and opened a PR. @@ -87,6 +90,18 @@ describe('AutofixOverview', () => { 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 issues query stays + // gated off. + PageFiltersStore.onInitializeUrlState(PageFiltersFixture()); + ProjectsStore.loadInitialData([ProjectFixture()]); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [ProjectFixture()], + }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, @@ -112,21 +127,10 @@ describe('AutofixOverview', () => { { 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.', - }, + // reviewer_notes answers empty on fix cards, and empty answers + // never become entries. ], }, ], @@ -180,6 +184,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 +197,63 @@ 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.getByRole('radio', {name: 'Table view'})).toBeChecked(); + expect(screen.queryByText('Root cause')).not.toBeInTheDocument(); + const shortId = screen.getByText('PROJ-1'); + const impactStats = screen.getByText(/100 events · 5 users/); + const projectIcon = screen.getByRole('link', {name: 'View Project Details'}); expect( - screen.getByText('Commit c5bb895 stopped sending the Authorization header.') - ).not.toBeVisible(); - expect(screen.getByText('PROJ-1')).not.toBeVisible(); + projectIcon.compareDocumentPosition(shortId) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + shortId.compareDocumentPosition(impactStats) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + 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 +266,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,38 +282,10 @@ 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(); + // No drafted fix → no fix section; the notes read as the next step. 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(); + expect(screen.getByText('Decide whether to relax the constraint.')).toBeVisible(); }); it('applies the outcome filter with AND semantics', async () => { @@ -372,7 +359,7 @@ describe('AutofixOverview', () => { ); }); - it('shows merged state and enables the Merged PRs card when the API returns PR state', async () => { + it('shows merged state when the API returns PR state', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ @@ -389,22 +376,15 @@ 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(); + // The merged run wears a Merged tag (and its group header) instead of a + // Review PR action. + expect(await screen.findAllByText('Merged')).toHaveLength(2); 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 () => { + it('groups cards under collapsible status sections in triage order', async () => { // A merged, B awaiting PR review, C still processing → B, C, A. MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, @@ -448,20 +428,38 @@ describe('AutofixOverview', () => { renderPage(); - expect(await screen.findByText('Merged')).toBeInTheDocument(); + // One sticky section per status, in fixed triage order, with counts. + const reviewHeader = await screen.findByRole('button', { + name: 'Awaiting your review 1', + }); + const runningHeader = screen.getByRole('button', {name: 'Running 1'}); + const mergedHeader = screen.getByRole('button', {name: 'Merged 1'}); + expect( + reviewHeader.compareDocumentPosition(runningHeader) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + runningHeader.compareDocumentPosition(mergedHeader) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + // Cards land under their section: B needs review, C runs, A is merged. 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']); - }); - 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(mergedHeader); + expect(screen.queryByRole('link', {name: 'Issue A'})).not.toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'Issue B'})).toBeInTheDocument(); - const mergedCard = await screen.findByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('0'); + // The bulk toggle folds everything, then flips to Expand all. + await userEvent.click(screen.getByRole('button', {name: 'Collapse all'})); + expect(screen.queryByRole('link', {name: 'Issue B'})).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Expand all'})); + expect(screen.getByRole('link', {name: 'Issue A'})).toBeInTheDocument(); }); it('surfaces the blocking question when a run awaits user input', async () => { @@ -501,38 +499,237 @@ describe('AutofixOverview', () => { expect( await screen.findByText('Seer asked: Which environment should I target?') ).toBeInTheDocument(); + // …and the card lands in the Needs-your-input section. + expect(screen.getByRole('button', {name: 'Needs your input 1'})).toBeInTheDocument(); }); - it('normalizes space-less • bullets into a markdown list', async () => { + it('sections errored runs under the Errored group', 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: 'error', + 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: 'rca', + metadata: {step: 'root_cause'}, + }, + }, + { + id: 'b2', + timestamp: '2026-07-14T09:10:00Z', + message: { + role: 'assistant', + content: 'plan', + metadata: {step: 'solution'}, + }, }, ], }, - ], + }, }); renderPage(); - await userEvent.click(await screen.findByRole('button', {name: 'Full analysis'})); + expect(await screen.findByRole('button', {name: 'Errored 1'})).toBeInTheDocument(); + }); + + it('scopes the issue stream to the selected projects', async () => { + PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: [2]})); + const issuesRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + body: [issue], + }); - expect(screen.getByText('Confirm the header is not leaked.')).toBeVisible(); - expect(screen.getByText('Verify both headers work.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); + 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 issues request carries it. + expect(issuesRequest).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}/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: 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(); + + // 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 + // list's 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'}); + + // 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 list chrome and offers the way back, keeping the + // other params. + expect(screen.queryByRole('button', {name: /Outcome/})).not.toBeInTheDocument(); + const backLink = screen.getByRole('button', {name: 'All issues'}); + expect(backLink).toHaveAttribute('href', expect.not.stringContaining('id=2')); }); it('renders an error state and can retry', async () => { diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 1bb3a8d9b70f..fc185ab8c23c 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -2,34 +2,48 @@ import {useMemo} from 'react'; import styled from '@emotion/styled'; 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 {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 { - ATTENTION_META, - ATTENTION_REASONS, - getAttentionReason, - getTriageRank, -} from './attentionBadge'; +import {ATTENTION_META, ATTENTION_REASONS, getAttentionReason} from './attentionBadge'; import {buildOverviewRows} from './buildOverviewRows'; -import {IssueCard} from './issueCard'; +import {IssueCard, IssueTableRow} from './issueCard'; import {RUN_QUESTION_PROMPTS} from './runQuestions'; +import { + getStatusGroup, + STATUS_GROUP_META, + STATUS_GROUP_ORDER, + StatusGroupTooltip, + type StatusGroupKey, +} from './statusGroups'; import type {AttentionReason, AutofixOutcome} from './types'; // Only autofix runs. `source` is the run's origin surface (autofix, chat, @@ -67,12 +81,12 @@ const ATTENTION_FILTER_OPTIONS: Array<{ label: ATTENTION_META[value].label, })); -type QuickFilterValue = 'review_pr' | 'awaiting_input' | 'code_changes_ready' | 'merged'; - -type SortValue = 'triage' | 'activity' | 'events'; +// Urgency ordering lives in the status groups now; the sort only orders +// cards within each group. +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')}, ]; @@ -96,17 +110,29 @@ export default function AutofixOverview() { const navigate = useNavigate(); const cursor = decodeScalar(location.query.cursor); + // 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 and pagination. + const selectedId = decodeScalar(location.query.id); 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; 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 + // issues request is 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 {issues, isPending, isError, refetch, pageLinks} = useAutofixIssues({ query: '', cursor, + enabled: pageFiltersReady, + groupIds: selectedId ? [selectedId] : undefined, + projects: selection.projects, runsQuery: OVERVIEW_RUNS_QUERY, questions: RUN_QUESTION_PROMPTS, }); @@ -115,16 +141,15 @@ export default function AutofixOverview() { navigate( { pathname: location.pathname, - query: {...location.query, ...patch}, + // Every caller changes a filter or the sort, where a stale cursor + // from a previous page makes no sense — reset it (the project filter + // already does the same via resetParamsOnChange). + query: {...location.query, cursor: undefined, ...patch}, }, {replace: true} ); }; - 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; @@ -150,72 +175,67 @@ export default function AutofixOverview() { 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. + // The sort orders cards within each status group; the groups themselves + // are fixed in triage order. 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 sortedRows = [...filteredRows].sort((a, b) => + sort === 'events' + ? b.row.eventCount - a.row.eventCount || byActivity(a, b) + : byActivity(a, b) + ); - const stats = { - reviewPr: 0, - awaitingInput: 0, - codeChangesReady: 0, - merged: 0, + // Focus mode shows the fetched issue as-is — client-side filters, sort, + // and grouping don't apply to a single deep-linked card. + const visibleRows = selectedId ? rowsWithAttention : sortedRows; + + // Linear-style sections in fixed triage order; empty groups don't render. + const groupedRows = STATUS_GROUP_ORDER.map( + groupKey => + [ + groupKey, + sortedRows.filter( + ({row, attention}) => getStatusGroup(row, attention) === groupKey + ), + ] as const + ).filter(([, rows]) => rows.length > 0); + + 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] + ); }; - 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 allGroupsCollapsed = + groupedRows.length > 0 && + groupedRows.every(([groupKey]) => collapsedGroups.includes(groupKey)); const hasActiveFilters = outcomeFilter.length > 0 || attentionFilter.length > 0 || - quickFilter !== undefined || (period !== undefined && period !== ''); const clearAllFilters = () => { updateQuery({ outcome: undefined, attention: undefined, - quick: undefined, period: undefined, }); }; @@ -226,76 +246,45 @@ export default function AutofixOverview() { features="seer-night-shift-ui" renderDisabled={() => } > - - - - - {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')} + + + ) : ( + // Filters first, unboxed, matching the issue stream's layout: + // server-side scope, then the client-side filters - + + + - {hasActiveFilters ? ( - - ) : null} + + {hasActiveFilters ? ( + + ) : null} + {groupedRows.length > 0 && ( + + )} + + 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 ? ( ) : isPending ? ( - ) : sortedRows.length === 0 ? ( + ) : visibleRows.length === 0 ? ( - {hasActiveFilters - ? t('No issues match your filters.') - : t('No completed autofix runs yet.')} + {selectedId + ? t('Issue not found.') + : hasActiveFilters + ? t('No issues match your filters.') + : t('No completed autofix runs yet.')} - ) : ( + ) : selectedId ? ( - {sortedRows.map(({row}) => ( - + {visibleRows.map(({row}) => ( + ))} + ) : ( + + {groupedRows.map(([groupKey, rows]) => { + const meta = STATUS_GROUP_META[groupKey]; + return ( + toggleGroup(groupKey, 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} + {rows.length} + + + + + {view === 'cards' ? ( + + {rows.map(({row}) => ( + + ))} + + ) : ( + + {rows.map(({row}, index) => ( + + ))} + + )} + + + ); + })} + )} - {!isPending && !isError && } - - - + {!selectedId && !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 +559,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 915a7ffafbb9..21448ad17cb1 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -2,8 +2,7 @@ import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; import {LinkButton} from '@sentry/scraps/button'; -import {Disclosure} from '@sentry/scraps/disclosure'; -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'; @@ -12,18 +11,11 @@ import {ErrorLevel} from 'sentry/components/events/errorLevel'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {TimeSince} from 'sentry/components/timeSince'; -import { - IconArrow, - IconCircleCheckmark, - IconCommit, - IconFocus, - IconMerge, - IconPullRequest, - IconSearch, -} from 'sentry/icons'; +import {IconArrow, IconCommit, IconFocus, IconMerge, IconPullRequest} from 'sentry/icons'; 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'; @@ -40,6 +32,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}) { @@ -49,9 +53,9 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { {shown.map(file => ( - + {file.path} - + +{file.added} @@ -71,37 +75,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') @@ -113,18 +132,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()) + } + > + + {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 && ( } @@ -176,212 +232,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()) - } + {/* 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} + + + @@ -389,3 +308,147 @@ 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 ; + } + return ( + + + {t('View run')} + + + ); +} 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/statusGroups.tsx b/static/app/views/seerWorkflows/overview/statusGroups.tsx new file mode 100644 index 000000000000..f463febbc8cd --- /dev/null +++ b/static/app/views/seerWorkflows/overview/statusGroups.tsx @@ -0,0 +1,155 @@ +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, OverviewRow} 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'; + +// Most urgent first — the group order IS the old "Needs you first" sort. +// Everything human-actionable (including manual investigation) sits above +// Running, which needs nothing from the reader. +export const STATUS_GROUP_ORDER: StatusGroupKey[] = [ + 'awaiting_input', + 'review_pr', + 'code_changes_ready', + 'solution_ready', + 'errored', + '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}`} + + ) + )} + + ); +} + +/** + * Which section a row belongs to. Precedence mirrors the card's own header + * logic: a merged win outranks everything, an in-flight run has nothing + * actionable yet, then the attention states. The remainder — diagnosis-only + * runs (and rows whose state is still loading) — needs a human to work the + * manual next steps: `getAttentionReason` returning null only means no + * one-click pipeline button exists, not that there is nothing to do. + */ +export function getStatusGroup( + row: OverviewRow, + attention: AttentionReason | null +): StatusGroupKey { + if (row.prMerged) { + return 'merged'; + } + if (row.isProcessing) { + return 'running'; + } + if (attention) { + return attention; + } + return 'needs_investigation'; +} 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