From 4c26bf2a01cdcbc73291a098ce46eb39370c46e6 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 15:50:24 -0400 Subject: [PATCH 01/34] feat(seer): Replace the level line with an autofix step indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview cards opened with the issue-level line, which says nothing about the run. Replace it with a two-glyph unit: a pie fill for how far through the pipeline the run got and a step icon (drawer v3 vocabulary) for which stage that is, tinted by run status — pulsing accent while running, warning when awaiting input, danger on error, all-success with a merge glyph once the PR merged. A shared tooltip carries the step checklist. The level moves into the Full analysis identity strip. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 101 +++++++++ .../seerWorkflows/overview/issueCard.tsx | 10 +- .../seerWorkflows/overview/stepIndicator.tsx | 207 ++++++++++++++++++ 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/stepIndicator.tsx diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 82d47892743b..237da418b4a4 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -175,6 +175,15 @@ describe('AutofixOverview', () => { 'https://github.com/getsentry/sentry/pull/123' ); + // The step indicator reads the full pipeline with the PR-opened glyph, + // and hovering it reveals the step checklist. + const indicator = screen.getByRole('img', { + name: 'Autofix progress: 4 of 4 steps — PR opened', + }); + await userEvent.hover(indicator); + expect(await screen.findByText('✓ Root cause')).toBeInTheDocument(); + expect(screen.getByText('✓ PR opened')).toBeInTheDocument(); + // Exact patch stats from merged_file_patches, not an LLM estimate. expect(screen.getByText('1 file')).toBeInTheDocument(); expect(screen.getByText('+42')).toBeInTheDocument(); @@ -231,6 +240,9 @@ describe('AutofixOverview', () => { 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(); + // The issue level moved off the header into the identity strip — exactly + // one occurrence (ErrorLevel's visually-hidden label). + expect(screen.getAllByText('Level: Warning')).toHaveLength(1); }); it('shows Diagnosis and Next steps when no code was drafted', async () => { @@ -370,6 +382,11 @@ describe('AutofixOverview', () => { 'href', `/organizations/${organization.slug}/issues/2/?seerDrawer=true` ); + + // A settled run gets no status word — just how far it got. + expect( + screen.getByRole('img', {name: 'Autofix progress: 1 of 4 steps — Root cause'}) + ).toBeInTheDocument(); }); it('shows merged state and enables the Merged PRs card when the API returns PR state', async () => { @@ -395,6 +412,11 @@ describe('AutofixOverview', () => { expect(await screen.findByText('Merged')).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); + // The step indicator renders its terminal all-success state. + expect( + screen.getByRole('img', {name: 'Autofix progress: PR merged'}) + ).toBeInTheDocument(); + // The Merged PRs stat card is live and counts the row. const mergedCard = screen.getByRole('button', {name: /Merged PRs/}); expect(mergedCard).toBeEnabled(); @@ -501,6 +523,85 @@ describe('AutofixOverview', () => { expect( await screen.findByText('Seer asked: Which environment should I target?') ).toBeInTheDocument(); + // The indicator's current step wears the paused status. + expect( + screen.getByRole('img', { + name: 'Autofix progress: 1 of 4 steps — Root cause (Needs your input)', + }) + ).toBeInTheDocument(); + }); + + it('shows a running step indicator for an in-flight run', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'processing', + 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(); + + expect( + await screen.findByRole('img', { + name: 'Autofix progress: 1 of 4 steps — Root cause (Running)', + }) + ).toBeInTheDocument(); + }); + + it('shows an errored step indicator at the step the run died on', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'error', + 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'}, + }, + }, + ], + }, + }, + }); + + renderPage(); + + expect( + await screen.findByRole('img', { + name: 'Autofix progress: 2 of 4 steps — Plan (Errored)', + }) + ).toBeInTheDocument(); }); it('normalizes space-less • bullets into a markdown list', async () => { diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 915a7ffafbb9..b10070cf430c 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -26,6 +26,7 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ellipsize} from 'sentry/utils/string/ellipsize'; import {ATTENTION_META, AttentionBadge, getAttentionReason} from './attentionBadge'; +import {StepIndicator} from './stepIndicator'; import {TriggerBadge} from './triggerBadge'; import type {OverviewRow, PatchStats} from './types'; @@ -117,7 +118,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {/* Header: title + change size + action */} - + {/* 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. @@ -147,9 +148,9 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { - {/* 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. */} + {/* No stage chip here: the step indicator up front carries the + stage, the action verb carries what to do about it (Review + PR ⇒ PR opened, Open PR ⇒ code drafted, …). */} {row.patchStats && ( } @@ -281,6 +282,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { fixability read — the raw title lives in the headline tooltip, not here */} + {row.shortId} diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx new file mode 100644 index 000000000000..042d329c02c4 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -0,0 +1,207 @@ +import {keyframes} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import { + IconBug, + IconCircle, + IconCircleCheckmark, + IconCode, + IconList, + IconMerge, + IconPieHalf, + IconPieQuarter, + IconPieThreeQuarters, + IconPullRequest, +} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {t} from 'sentry/locale'; + +import type {AutofixOutcome, OverviewRow} from './types'; + +const STEP_ORDER: AutofixOutcome[] = [ + 'root_cause', + 'solution', + 'code_changes', + 'pr_opened', +]; + +// Same icon vocabulary as the Seer drawer's v3 cards, so the glyph on the +// card matches the section the user lands on after clicking through. +const STEP_META: Record< + AutofixOutcome, + {Icon: React.ComponentType; label: string} +> = { + root_cause: {Icon: IconBug, label: t('Root cause')}, + solution: {Icon: IconList, label: t('Plan')}, + code_changes: {Icon: IconCode, label: t('Code changes')}, + pr_opened: {Icon: IconPullRequest, label: t('PR opened')}, +}; + +// Not ProgressMarker: it hardcodes a color per fill level (half = warning, +// three-quarters = success), which would fight the status-driven variant. +const PIE_BY_FILL = [ + IconCircle, + IconPieQuarter, + IconPieHalf, + IconPieThreeQuarters, + IconCircleCheckmark, +] as const; + +type IndicatorVariant = 'accent' | 'danger' | 'muted' | 'success' | 'warning'; + +function deriveStatus(row: OverviewRow): { + variant: IndicatorVariant; + statusWord?: string; +} { + if (row.isProcessing) { + return {variant: 'accent', statusWord: t('Running')}; + } + if (row.autofixRunStatus === 'NEED_MORE_INFORMATION') { + return {variant: 'warning', statusWord: t('Needs your input')}; + } + if (row.autofixRunStatus === 'ERROR') { + return {variant: 'danger', statusWord: t('Errored')}; + } + if (row.prMerged) { + return {variant: 'success'}; + } + return {variant: 'muted'}; +} + +function StepChecklist({ + fill, + merged, + statusWord, + variant, +}: { + fill: number; + merged: boolean; + statusWord: string | undefined; + variant: IndicatorVariant; +}) { + return ( + + {STEP_ORDER.map((step, index) => { + const {label} = STEP_META[step]; + if (index === fill - 1 && statusWord && !merged) { + return ( + + {`${label} — ${statusWord}`} + + ); + } + if (index < fill || merged) { + return ( + + {`✓ ${label}`} + + ); + } + return ( + + {`○ ${label}`} + + ); + })} + {merged && ( + + {`✓ ${t('PR merged')}`} + + )} + {fill === 0 && statusWord && ( + + {statusWord} + + )} + + ); +} + +/** + * Where the run is in the autofix pipeline, as a two-glyph unit: a pie fill + * for how far it got (quarter per stage) and a step icon for which stage + * that is, tinted by run status. Replaces the issue-level line the cards + * used to open with — on this page every card is a Seer run, so the run's + * progress is the fact worth a glance, not the issue's level. + */ +export function StepIndicator({row}: {row: OverviewRow}) { + if (row.statePending) { + return ( + + + + ); + } + + const {variant, statusWord} = deriveStatus(row); + const furthest = row.outcomes.at(-1); + // Index against the canonical order rather than counting outcomes: a + // coding-agent run can skip the solution stage, so the array's length can + // lag the stage it actually reached. + const fill = furthest ? STEP_ORDER.indexOf(furthest) + 1 : row.isProcessing ? 1 : 0; + // A processing run streams its section in before any outcome lands, so an + // empty run that's running is by definition on the first step. + const currentStep = furthest ?? (row.isProcessing ? 'root_cause' : undefined); + + const Pie = row.prMerged ? IconCircleCheckmark : (PIE_BY_FILL[fill] ?? IconCircle); + const StepIcon = row.prMerged ? IconMerge : currentStep && STEP_META[currentStep].Icon; + const stepLabel = currentStep ? STEP_META[currentStep].label : undefined; + + const ariaLabel = row.prMerged + ? t('Autofix progress: PR merged') + : stepLabel + ? statusWord + ? t('Autofix progress: %s of 4 steps — %s (%s)', fill, stepLabel, statusWord) + : t('Autofix progress: %s of 4 steps — %s', fill, stepLabel) + : t('Autofix progress: no steps completed'); + + return ( + + } + skipWrapper + > + + + {StepIcon && + (row.isProcessing ? ( + + + + ) : ( + + ))} + + + ); +} + +const pulse = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +`; + +const PulseSpan = styled('span')` + display: inline-flex; + line-height: 0; + animation: ${pulse} 2s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +`; From 1f84721af1dd20050a32c549f3214db9d45f2d8e Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:08:12 -0400 Subject: [PATCH 02/34] feat(seer): Treat the merged PR as the pipeline's fifth step A quarter per Seer stage now fills the circle at PR opened; the checkmark is reserved for the merged PR, and the checklist always shows Merged as the finale. Also opens up the header spacing so the glyph pair reads as one unit apart from the title. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 18 +++++++------- .../seerWorkflows/overview/issueCard.tsx | 2 +- .../seerWorkflows/overview/stepIndicator.tsx | 24 +++++++++++++------ 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 237da418b4a4..a9e9aed96b89 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -175,14 +175,16 @@ describe('AutofixOverview', () => { 'https://github.com/getsentry/sentry/pull/123' ); - // The step indicator reads the full pipeline with the PR-opened glyph, - // and hovering it reveals the step checklist. + // The step indicator reads the full Seer pipeline with the PR-opened + // glyph — merge is the outstanding final step. Hovering reveals the + // checklist. const indicator = screen.getByRole('img', { - name: 'Autofix progress: 4 of 4 steps — PR opened', + name: 'Autofix progress: 4 of 5 steps — PR opened', }); await userEvent.hover(indicator); expect(await screen.findByText('✓ Root cause')).toBeInTheDocument(); expect(screen.getByText('✓ PR opened')).toBeInTheDocument(); + expect(screen.getByText('○ Merged')).toBeInTheDocument(); // Exact patch stats from merged_file_patches, not an LLM estimate. expect(screen.getByText('1 file')).toBeInTheDocument(); @@ -385,7 +387,7 @@ describe('AutofixOverview', () => { // A settled run gets no status word — just how far it got. expect( - screen.getByRole('img', {name: 'Autofix progress: 1 of 4 steps — Root cause'}) + screen.getByRole('img', {name: 'Autofix progress: 1 of 5 steps — Root cause'}) ).toBeInTheDocument(); }); @@ -414,7 +416,7 @@ describe('AutofixOverview', () => { // The step indicator renders its terminal all-success state. expect( - screen.getByRole('img', {name: 'Autofix progress: PR merged'}) + screen.getByRole('img', {name: 'Autofix progress: 5 of 5 steps — PR merged'}) ).toBeInTheDocument(); // The Merged PRs stat card is live and counts the row. @@ -526,7 +528,7 @@ describe('AutofixOverview', () => { // The indicator's current step wears the paused status. expect( screen.getByRole('img', { - name: 'Autofix progress: 1 of 4 steps — Root cause (Needs your input)', + name: 'Autofix progress: 1 of 5 steps — Root cause (Needs your input)', }) ).toBeInTheDocument(); }); @@ -558,7 +560,7 @@ describe('AutofixOverview', () => { expect( await screen.findByRole('img', { - name: 'Autofix progress: 1 of 4 steps — Root cause (Running)', + name: 'Autofix progress: 1 of 5 steps — Root cause (Running)', }) ).toBeInTheDocument(); }); @@ -599,7 +601,7 @@ describe('AutofixOverview', () => { expect( await screen.findByRole('img', { - name: 'Autofix progress: 2 of 4 steps — Plan (Errored)', + name: 'Autofix progress: 2 of 5 steps — Plan (Errored)', }) ).toBeInTheDocument(); }); diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index b10070cf430c..e4562d270416 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -117,7 +117,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {/* Header: title + change size + action */} - + {/* The ellipsis Text is the shrinking flex item (overflow:hidden resolves its min-width to 0); the Link must nest inside it or diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 042d329c02c4..45a45fa38c67 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -9,6 +9,7 @@ import { IconBug, IconCircle, IconCircleCheckmark, + IconCircleFill, IconCode, IconList, IconMerge, @@ -43,12 +44,15 @@ const STEP_META: Record< // Not ProgressMarker: it hardcodes a color per fill level (half = warning, // three-quarters = success), which would fight the status-driven variant. +// The pipeline is five steps with merge as the finale: a quarter per Seer +// stage fills the circle at PR opened, and the checkmark is reserved for +// the merged PR. const PIE_BY_FILL = [ IconCircle, IconPieQuarter, IconPieHalf, IconPieThreeQuarters, - IconCircleCheckmark, + IconCircleFill, ] as const; type IndicatorVariant = 'accent' | 'danger' | 'muted' | 'success' | 'warning'; @@ -107,9 +111,13 @@ function StepChecklist({ ); })} - {merged && ( + {merged ? ( - {`✓ ${t('PR merged')}`} + {`✓ ${t('Merged')}`} + + ) : ( + + {`○ ${t('Merged')}`} )} {fill === 0 && statusWord && ( @@ -157,11 +165,11 @@ export function StepIndicator({row}: {row: OverviewRow}) { const stepLabel = currentStep ? STEP_META[currentStep].label : undefined; const ariaLabel = row.prMerged - ? t('Autofix progress: PR merged') + ? t('Autofix progress: 5 of 5 steps — PR merged') : stepLabel ? statusWord - ? t('Autofix progress: %s of 4 steps — %s (%s)', fill, stepLabel, statusWord) - : t('Autofix progress: %s of 4 steps — %s', fill, stepLabel) + ? t('Autofix progress: %s of 5 steps — %s (%s)', fill, stepLabel, statusWord) + : t('Autofix progress: %s of 5 steps — %s', fill, stepLabel) : t('Autofix progress: no steps completed'); return ( @@ -176,7 +184,9 @@ export function StepIndicator({row}: {row: OverviewRow}) { } skipWrapper > - + {/* Tighter gap inside the pair than between the pair and the title, + so the two glyphs read as one unit rather than three loose marks. */} + {StepIcon && (row.isProcessing ? ( From 7245e1220ed0fa57a27f26962a87050fba358f95 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:17:52 -0400 Subject: [PATCH 03/34] fix(seer): Fill the step indicator in fifths so PR-opened isn't full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pie icons only come in quarters, so an unmerged PR rendered a full circle — reading as done with the merge step still outstanding. Swap the glyph for a ProgressRing filled a fifth per step (4/5 at PR opened); the checkmark stays reserved for the merged PR. The ring color resolves through the same variant mapping the icons use. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 45a45fa38c67..8806911ef999 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -1,21 +1,17 @@ -import {keyframes} from '@emotion/react'; +import {keyframes, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; +import {ProgressRing} from 'sentry/components/progressRing'; import { IconBug, - IconCircle, IconCircleCheckmark, - IconCircleFill, IconCode, IconList, IconMerge, - IconPieHalf, - IconPieQuarter, - IconPieThreeQuarters, IconPullRequest, } from 'sentry/icons'; import type {SVGIconProps} from 'sentry/icons/svgIcon'; @@ -42,18 +38,13 @@ const STEP_META: Record< pr_opened: {Icon: IconPullRequest, label: t('PR opened')}, }; -// Not ProgressMarker: it hardcodes a color per fill level (half = warning, -// three-quarters = success), which would fight the status-driven variant. -// The pipeline is five steps with merge as the finale: a quarter per Seer -// stage fills the circle at PR opened, and the checkmark is reserved for -// the merged PR. -const PIE_BY_FILL = [ - IconCircle, - IconPieQuarter, - IconPieHalf, - IconPieThreeQuarters, - IconCircleFill, -] as const; +// The pipeline is five steps with merge as the finale, so the progress +// glyph is a ring filled in fifths (the pie icons only come in quarters, +// which would misread PR-opened as done). The checkmark is reserved for +// the merged PR. Matches the icons' size="sm". +const RING_SIZE = 14; +const RING_BAR_WIDTH = 2; +const TOTAL_STEPS = STEP_ORDER.length + 1; type IndicatorVariant = 'accent' | 'danger' | 'muted' | 'success' | 'warning'; @@ -130,13 +121,15 @@ function StepChecklist({ } /** - * Where the run is in the autofix pipeline, as a two-glyph unit: a pie fill - * for how far it got (quarter per stage) and a step icon for which stage - * that is, tinted by run status. Replaces the issue-level line the cards - * used to open with — on this page every card is a Seer run, so the run's - * progress is the fact worth a glance, not the issue's level. + * Where the run is in the autofix pipeline, as a two-glyph unit: a progress + * ring filled a fifth per step and a step icon for which step that is, + * tinted by run status. Replaces the issue-level line the cards used to + * open with — on this page every card is a Seer run, so the run's progress + * is the fact worth a glance, not the issue's level. */ export function StepIndicator({row}: {row: OverviewRow}) { + const theme = useTheme(); + if (row.statePending) { return ( - + ); } const {variant, statusWord} = deriveStatus(row); + // Same variant → color resolution the icons use (svgIcon.tsx), so the + // ring and the step icon beside it always agree. + const ringColor = + variant === 'warning' + ? theme.tokens.graphics.warning.vibrant + : theme.tokens.content[variant === 'muted' ? 'secondary' : variant]; const furthest = row.outcomes.at(-1); // Index against the canonical order rather than counting outcomes: a // coding-agent run can skip the solution stage, so the array's length can @@ -160,7 +165,6 @@ export function StepIndicator({row}: {row: OverviewRow}) { // empty run that's running is by definition on the first step. const currentStep = furthest ?? (row.isProcessing ? 'root_cause' : undefined); - const Pie = row.prMerged ? IconCircleCheckmark : (PIE_BY_FILL[fill] ?? IconCircle); const StepIcon = row.prMerged ? IconMerge : currentStep && STEP_META[currentStep].Icon; const stepLabel = currentStep ? STEP_META[currentStep].label : undefined; @@ -187,7 +191,19 @@ export function StepIndicator({row}: {row: OverviewRow}) { {/* Tighter gap inside the pair than between the pair and the title, so the two glyphs read as one unit rather than three loose marks. */} - + {row.prMerged ? ( + + ) : ( + + )} {StepIcon && (row.isProcessing ? ( From ffcdb73d35147f46e3bbc79e88d3a1578ac59437 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:25:45 -0400 Subject: [PATCH 04/34] ref(seer): Drop the step icon beside the progress ring The ring plus its tooltip checklist carry the step on their own; the extra icon read as clutter. The processing pulse moves onto the ring, and the step metadata slims down to labels. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 76 +++++++------------ 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 8806911ef999..aace2b961489 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -6,15 +6,7 @@ import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {ProgressRing} from 'sentry/components/progressRing'; -import { - IconBug, - IconCircleCheckmark, - IconCode, - IconList, - IconMerge, - IconPullRequest, -} from 'sentry/icons'; -import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {IconCircleCheckmark} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {AutofixOutcome, OverviewRow} from './types'; @@ -26,16 +18,11 @@ const STEP_ORDER: AutofixOutcome[] = [ 'pr_opened', ]; -// Same icon vocabulary as the Seer drawer's v3 cards, so the glyph on the -// card matches the section the user lands on after clicking through. -const STEP_META: Record< - AutofixOutcome, - {Icon: React.ComponentType; label: string} -> = { - root_cause: {Icon: IconBug, label: t('Root cause')}, - solution: {Icon: IconList, label: t('Plan')}, - code_changes: {Icon: IconCode, label: t('Code changes')}, - pr_opened: {Icon: IconPullRequest, label: t('PR opened')}, +const STEP_LABELS: Record = { + root_cause: t('Root cause'), + solution: t('Plan'), + code_changes: t('Code changes'), + pr_opened: t('PR opened'), }; // The pipeline is five steps with merge as the finale, so the progress @@ -81,7 +68,7 @@ function StepChecklist({ return ( {STEP_ORDER.map((step, index) => { - const {label} = STEP_META[step]; + const label = STEP_LABELS[step]; if (index === fill - 1 && statusWord && !merged) { return ( @@ -121,11 +108,11 @@ function StepChecklist({ } /** - * Where the run is in the autofix pipeline, as a two-glyph unit: a progress - * ring filled a fifth per step and a step icon for which step that is, - * tinted by run status. Replaces the issue-level line the cards used to - * open with — on this page every card is a Seer run, so the run's progress - * is the fact worth a glance, not the issue's level. + * Where the run is in the autofix pipeline: a progress ring filled a fifth + * per step, tinted by run status, with the step checklist in its tooltip. + * Replaces the issue-level line the cards used to open with — on this page + * every card is a Seer run, so the run's progress is the fact worth a + * glance, not the issue's level. */ export function StepIndicator({row}: {row: OverviewRow}) { const theme = useTheme(); @@ -165,8 +152,7 @@ export function StepIndicator({row}: {row: OverviewRow}) { // empty run that's running is by definition on the first step. const currentStep = furthest ?? (row.isProcessing ? 'root_cause' : undefined); - const StepIcon = row.prMerged ? IconMerge : currentStep && STEP_META[currentStep].Icon; - const stepLabel = currentStep ? STEP_META[currentStep].label : undefined; + const stepLabel = currentStep ? STEP_LABELS[currentStep] : undefined; const ariaLabel = row.prMerged ? t('Autofix progress: 5 of 5 steps — PR merged') @@ -176,6 +162,18 @@ export function StepIndicator({row}: {row: OverviewRow}) { : t('Autofix progress: %s of 5 steps — %s', fill, stepLabel) : t('Autofix progress: no steps completed'); + const ring = ( + + ); + return ( - {/* Tighter gap inside the pair than between the pair and the title, - so the two glyphs read as one unit rather than three loose marks. */} - + {row.prMerged ? ( + ) : row.isProcessing ? ( + {ring} ) : ( - + ring )} - {StepIcon && - (row.isProcessing ? ( - - - - ) : ( - - ))} ); From 2219d21e01f8d2f0f26e41298a2251e0d9fb41fb Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:34:21 -0400 Subject: [PATCH 05/34] ref(seer): Tuck the analysis + vitals line under the card title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Full analysis disclosure and the provenance/vitals run lived in a bordered footer, adding a full row of vertical space per card. They now sit as a quiet subline directly under the title — tight xs gap to the header so they read as one title block, md rhythm to the body kept, no divider needed. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/issueCard.tsx | 435 +++++++++--------- 1 file changed, 222 insertions(+), 213 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index e4562d270416..c85768419e58 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -115,122 +115,250 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { return ( - {/* Header: title + change size + action */} - - - - {/* The ellipsis Text is the shrinking flex item (overflow:hidden + {/* Title block: the header row plus its meta subline, tied together + with a tight gap so the subline reads as part of the title while + the card keeps its md rhythm between blocks */} + + {/* Header: title + change size + action */} + + + + {/* 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. */} - - {row.headline ? ( + + {row.headline ? ( + + + {t('Raw issue title')} + + + {ellipsize(row.title, 200)} + + + } + > + {row.headline} + + ) : ( + {row.title} + )} + + + + {/* No stage chip here: the step indicator up front carries the + stage, the action verb carries what to do about it (Review + PR ⇒ PR opened, Open PR ⇒ code drafted, …). */} + {row.patchStats && ( } maxWidth={480} - title={ - - - {t('Raw issue title')} - - - {ellipsize(row.title, 200)} + skipWrapper + > + {/* Contained like its Tag/button neighbors so the diff size + doesn't read as floating text */} + + + {tn('%s file', '%s files', row.patchStats.files)}{' '} + + +{row.patchStats.added} + {' '} + + −{row.patchStats.removed} - + + + + )} + {row.statePending ? ( + {'…'} + ) : row.isProcessing ? ( + {t('Running')} + ) : row.prMerged ? ( + + }> + {t('Merged')} + + + ) : attention === 'review_pr' && row.prUrl ? ( + - {row.headline} + } + href={row.prUrl} + external + > + {ATTENTION_META.review_pr.label} + + ) : attention ? ( + ) : ( - {row.title} + + + {t('View run')} + + )} - - - - {/* No stage chip here: the step indicator up front carries the - stage, the action verb carries what to do about it (Review - PR ⇒ PR opened, Open PR ⇒ code drafted, …). */} - {row.patchStats && ( - } - maxWidth={480} - skipWrapper - > - {/* Contained like its Tag/button neighbors so the diff size - doesn't read as floating text */} - - - {tn('%s file', '%s files', row.patchStats.files)}{' '} - - +{row.patchStats.added} - {' '} - - −{row.patchStats.removed} - - - - - )} - {row.statePending ? ( - {'…'} - ) : row.isProcessing ? ( - {t('Running')} - ) : row.prMerged ? ( - - }> - {t('Merged')} - - - ) : attention === 'review_pr' && row.prUrl ? ( - + {row.prUrl && attention !== 'review_pr' && ( } href={row.prUrl} external > - {ATTENTION_META.review_pr.label} - - - ) : attention ? ( - - ) : ( - - - {t('View run')} + {row.prNumber ? `#${row.prNumber}` : t('PR')} + )} + + + {/* Meta subline: the collapsed analysis on the left, provenance + + vitals as one quiet metadata run on the right */} + + + {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} + + + + + + + ); + })} + + + + + )} + + {/* "Manual" is the default trigger and reads as noise on every + card; only non-default triggers earn a badge. The xs top padding + optically centers the run against the disclosure button text. */} + + {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() + ) + } + > + + {eventCountLabel} + {row.userCount > 0 && ` · ${userCountLabel}`} + + + + {'·'} + + + + + + + - )} - {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 && ( @@ -268,125 +396,6 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { )} - - {/* 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()) - } - > - - {eventCountLabel} - {row.userCount > 0 && ` · ${userCountLabel}`} - - - - {'·'} - - - - - - - - - - ); From 5a8bfdcccbf3eb93fad8df2336d603e67ec59b9d Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:42:47 -0400 Subject: [PATCH 06/34] ref(seer): Vitals under the title, analysis toggle at the card's tail The events/updated run now sits as a quiet subline directly under the title (ring nudged to center on the title's first line). The Full analysis trigger anchors the bottom row with the project badge inline at the right, and the analysis itself expands as a full-width block so its two-column grid gets the whole card. The core Disclosure gave way to a small controlled toggle since trigger and panel now live in different rows. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 9 +- .../seerWorkflows/overview/issueCard.tsx | 395 +++++++++--------- 2 files changed, 214 insertions(+), 190 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index a9e9aed96b89..f2e588f95ab5 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -217,12 +217,13 @@ describe('AutofixOverview', () => { // The timestamp is labeled as run activity. expect(screen.getByText(/^updated/)).toBeInTheDocument(); - // Root cause, notes, and the short id stay behind the disclosure. + // Root cause, notes, and the short id stay behind the analysis toggle, + // which renders its block only when expanded. 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(); + screen.queryByText('Commit c5bb895 stopped sending the Authorization header.') + ).not.toBeInTheDocument(); + expect(screen.queryByText('PROJ-1')).not.toBeInTheDocument(); await userEvent.click(disclosure); diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index c85768419e58..420302b80134 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -1,8 +1,8 @@ +import {useId, useState} from 'react'; import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; -import {LinkButton} from '@sentry/scraps/button'; -import {Disclosure} from '@sentry/scraps/disclosure'; +import {Button, LinkButton} from '@sentry/scraps/button'; import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; @@ -14,6 +14,7 @@ import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {TimeSince} from 'sentry/components/timeSince'; import { IconArrow, + IconChevron, IconCircleCheckmark, IconCommit, IconFocus, @@ -38,6 +39,15 @@ const TitleLink = styled(Link)` } `; +// The "Full analysis" trigger sheds the Button's horizontal padding so it +// sits flush with the card's left edge like the rows above it. (Not core +// Disclosure: the trigger anchors the bottom row while the analysis expands +// as a separate full-width block below.) +const AnalysisToggle = styled(Button)` + padding-left: 0; + padding-right: 0; +`; + // The most-changed files shown on hover before collapsing into "+N more". const MAX_TOOLTIP_FILES = 5; @@ -102,6 +112,8 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { const bodyEntry = proposedFix ?? summary; const isFixBody = bodyEntry?.key === 'fix_summary'; const detailEntries = row.analysis.filter(entry => entry.placement === 'details'); + const [analysisExpanded, setAnalysisExpanded] = useState(false); + const analysisId = useId(); const eventCountLabel = row.eventCount === 1 @@ -115,14 +127,15 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { return ( - {/* Title block: the header row plus its meta subline, tied together - with a tight gap so the subline reads as part of the title while - the card keeps its md rhythm between blocks */} - - {/* Header: title + change size + action */} - - + {/* Header: ring + (title over its metadata subline) on the left, + change size / action pinned right */} + + + {/* Nudge the ring down to center it on the title's first line */} + + + {/* 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. @@ -150,179 +163,11 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {row.title} )} - - - {/* No stage chip here: the step indicator up front carries the - stage, the action verb carries what to do about it (Review - PR ⇒ PR opened, Open PR ⇒ code drafted, …). */} - {row.patchStats && ( - } - maxWidth={480} - skipWrapper - > - {/* Contained like its Tag/button neighbors so the diff size - doesn't read as floating text */} - - - {tn('%s file', '%s files', row.patchStats.files)}{' '} - - +{row.patchStats.added} - {' '} - - −{row.patchStats.removed} - - - - - )} - {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')} - - )} - - - {/* Meta subline: the collapsed analysis on the left, provenance + - vitals as one quiet metadata run on the right */} - - - {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} - - - - - - - ); - })} - - - - - )} - - {/* "Manual" is the default trigger and reads as noise on every - card; only non-default triggers earn a badge. The xs top padding - optically centers the run against the disclosure button text. */} - - {row.trigger !== 'manual' && ( - - )} - + {/* 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 @@ -352,13 +197,96 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { tooltipPrefix={t('Last activity on this Seer run')} /> + {row.trigger !== 'manual' && ( + + )} - - + + + + {/* No stage chip here: the step indicator up front carries the + stage, the action verb carries what to do about it (Review + PR ⇒ PR opened, Open PR ⇒ code drafted, …). */} + {row.patchStats && ( + } + maxWidth={480} + skipWrapper + > + {/* Contained like its Tag/button neighbors so the diff size + doesn't read as floating text */} + + + {tn('%s file', '%s files', row.patchStats.files)}{' '} + + +{row.patchStats.added} + {' '} + + −{row.patchStats.removed} + + + - + )} + {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 && ( @@ -396,6 +324,101 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { )} + + {/* Bottom row: the analysis toggle anchors the card's tail on the + left, project provenance sits inline at the right */} + + + {detailEntries.length > 0 && ( + + } + aria-expanded={analysisExpanded} + aria-controls={analysisId} + onClick={() => setAnalysisExpanded(expanded => !expanded)} + > + {t('Full analysis')} + + )} + + + + + + + {/* The expanded analysis is its own full-width block (rather than + nested under the toggle) so the two-column grid gets the whole + card width */} + {analysisExpanded && detailEntries.length > 0 && ( + + {/* Compact identity strip: the level, 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} + + + + + + + ); + })} + + + )} ); From 40184c0f9a088c4b5562a00709fdb53a3990075c Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:43:57 -0400 Subject: [PATCH 07/34] fix(seer): Center the progress ring on the title block and size it up The ring now vertically centers against the title + vitals subline instead of hanging off the first line, and grows to 18px so it reads at a glance. Co-Authored-By: Claude Fable 5 --- static/app/views/seerWorkflows/overview/issueCard.tsx | 7 ++----- static/app/views/seerWorkflows/overview/stepIndicator.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 420302b80134..86ae2c851935 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -130,11 +130,8 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {/* Header: ring + (title over its metadata subline) on the left, change size / action pinned right */} - - {/* Nudge the ring down to center it on the title's first line */} - - - + + {/* The ellipsis Text is the shrinking flex item (overflow:hidden resolves its min-width to 0); the Link must nest inside it or diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index aace2b961489..9a724ce34b6f 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -28,9 +28,9 @@ const STEP_LABELS: Record = { // The pipeline is five steps with merge as the finale, so the progress // glyph is a ring filled in fifths (the pie icons only come in quarters, // which would misread PR-opened as done). The checkmark is reserved for -// the merged PR. Matches the icons' size="sm". -const RING_SIZE = 14; -const RING_BAR_WIDTH = 2; +// the merged PR and sized to match the ring. +const RING_SIZE = 18; +const RING_BAR_WIDTH = 2.5; const TOTAL_STEPS = STEP_ORDER.length + 1; type IndicatorVariant = 'accent' | 'danger' | 'muted' | 'success' | 'warning'; @@ -188,7 +188,7 @@ export function StepIndicator({row}: {row: OverviewRow}) { > {row.prMerged ? ( - + ) : row.isProcessing ? ( {ring} ) : ( From 7fb057a3dafb90a21c8211198cbeb800c9093102 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 16:47:35 -0400 Subject: [PATCH 08/34] feat(seer): Full-row analysis toggle, linked project badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Full analysis trigger now stretches across the card's tail so the whole row is a click target (label pinned left) and steps up from the zero-size button font to xs. The project badge links to its project page — ProjectBadge's canonical destination. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/issueCard.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 86ae2c851935..41651ac2a66c 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -39,11 +39,14 @@ const TitleLink = styled(Link)` } `; -// The "Full analysis" trigger sheds the Button's horizontal padding so it -// sits flush with the card's left edge like the rows above it. (Not core -// Disclosure: the trigger anchors the bottom row while the analysis expands -// as a separate full-width block below.) +// The "Full analysis" trigger stretches across its row so the whole tail of +// the card is a click target (label pinned left), shedding the Button's +// horizontal padding to sit flush with the rows above. (Not core Disclosure: +// the trigger anchors the bottom row while the analysis expands as a +// separate full-width block below.) const AnalysisToggle = styled(Button)` + width: 100%; + justify-content: flex-start; padding-left: 0; padding-right: 0; `; @@ -322,13 +325,14 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { )} - {/* Bottom row: the analysis toggle anchors the card's tail on the - left, project provenance sits inline at the right */} + {/* Bottom row: the analysis toggle stretches across the card's tail + (whole row toggles), project provenance inline at the right, + linking to its project page */} - + {detailEntries.length > 0 && ( )} - - + + From 032ee7d7c771898245cf023366e40045ea369a83 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 17:21:39 -0400 Subject: [PATCH 09/34] feat(seer): Count the steps left to a fix inside the progress ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring now centers a small status-colored digit — steps remaining until the fix is merged — and its tooltip leads with the verdict: "1 step until issue fix", or "Issue fixed" once merged. Untouched rings stay empty rather than shouting a 5. Ring nudged to 20px so the digit breathes. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 27 ++++++++++++------- .../seerWorkflows/overview/stepIndicator.tsx | 25 ++++++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index f2e588f95ab5..f28b84360392 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -181,8 +181,12 @@ describe('AutofixOverview', () => { const indicator = screen.getByRole('img', { name: 'Autofix progress: 4 of 5 steps — PR opened', }); + // The ring carries the steps-remaining count… + expect(indicator).toHaveTextContent('1'); + // …and its tooltip spells the count out above the checklist. await userEvent.hover(indicator); - expect(await screen.findByText('✓ Root cause')).toBeInTheDocument(); + expect(await screen.findByText('1 step until issue fix')).toBeInTheDocument(); + expect(screen.getByText('✓ Root cause')).toBeInTheDocument(); expect(screen.getByText('✓ PR opened')).toBeInTheDocument(); expect(screen.getByText('○ Merged')).toBeInTheDocument(); @@ -415,10 +419,14 @@ describe('AutofixOverview', () => { expect(await screen.findByText('Merged')).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); - // The step indicator renders its terminal all-success state. - expect( - screen.getByRole('img', {name: 'Autofix progress: 5 of 5 steps — PR merged'}) - ).toBeInTheDocument(); + // The step indicator renders its terminal all-success state: checkmark + // only, no steps-remaining digit, and the tooltip leads with the verdict. + const indicator = screen.getByRole('img', { + name: 'Autofix progress: 5 of 5 steps — PR merged', + }); + expect(indicator).not.toHaveTextContent(/\d/); + await userEvent.hover(indicator); + expect(await screen.findByText('Issue fixed')).toBeInTheDocument(); // The Merged PRs stat card is live and counts the row. const mergedCard = screen.getByRole('button', {name: /Merged PRs/}); @@ -600,11 +608,10 @@ describe('AutofixOverview', () => { renderPage(); - expect( - await screen.findByRole('img', { - name: 'Autofix progress: 2 of 5 steps — Plan (Errored)', - }) - ).toBeInTheDocument(); + const indicator = await screen.findByRole('img', { + name: 'Autofix progress: 2 of 5 steps — Plan (Errored)', + }); + expect(indicator).toHaveTextContent('3'); }); it('normalizes space-less • bullets into a markdown list', async () => { diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 9a724ce34b6f..b91786d382e2 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -1,4 +1,4 @@ -import {keyframes, useTheme} from '@emotion/react'; +import {css, keyframes, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex, Stack} from '@sentry/scraps/layout'; @@ -7,7 +7,7 @@ import {Tooltip} from '@sentry/scraps/tooltip'; import {ProgressRing} from 'sentry/components/progressRing'; import {IconCircleCheckmark} from 'sentry/icons'; -import {t} from 'sentry/locale'; +import {t, tn} from 'sentry/locale'; import type {AutofixOutcome, OverviewRow} from './types'; @@ -29,7 +29,7 @@ const STEP_LABELS: Record = { // glyph is a ring filled in fifths (the pie icons only come in quarters, // which would misread PR-opened as done). The checkmark is reserved for // the merged PR and sized to match the ring. -const RING_SIZE = 18; +const RING_SIZE = 20; const RING_BAR_WIDTH = 2.5; const TOTAL_STEPS = STEP_ORDER.length + 1; @@ -57,16 +57,25 @@ function deriveStatus(row: OverviewRow): { function StepChecklist({ fill, merged, + remaining, statusWord, variant, }: { fill: number; merged: boolean; + remaining: number; statusWord: string | undefined; variant: IndicatorVariant; }) { return ( + {/* Headline: how close this issue is to fixed, matching the count + shown inside the ring */} + + {merged + ? t('Issue fixed') + : tn('%s step until issue fix', '%s steps until issue fix', remaining)} + {STEP_ORDER.map((step, index) => { const label = STEP_LABELS[step]; if (index === fill - 1 && statusWord && !merged) { @@ -162,6 +171,7 @@ export function StepIndicator({row}: {row: OverviewRow}) { : t('Autofix progress: %s of 5 steps — %s', fill, stepLabel) : t('Autofix progress: no steps completed'); + const remaining = TOTAL_STEPS - fill; const ring = ( 0 ? remaining : undefined} + textCss={() => css` + font-size: 9px; + font-weight: bold; + color: ${ringColor}; + `} aria-hidden /> ); @@ -180,6 +198,7 @@ export function StepIndicator({row}: {row: OverviewRow}) { From 0445adab51c106d838c5995d5d8fe3502633d407 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 18:07:48 -0400 Subject: [PATCH 10/34] feat(seer): Inline file differ on overview cards for small fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the drawer's FileDiffViewer against the structured patches the row builder already had in hand. Strictly gated — at most 2 files, 25 changed lines, 60 rendered hunk lines — and each qualifying file shows as a collapsed header (+N −N, path) that expands the highlighted diff in place, aligned with the body's text column. Oversized diffs keep today's pill + hover file list. Co-Authored-By: Claude Fable 5 --- .../overview/buildOverviewRows.ts | 41 +++++++-- .../seerWorkflows/overview/index.spec.tsx | 92 +++++++++++++++++++ .../seerWorkflows/overview/issueCard.tsx | 18 ++++ .../app/views/seerWorkflows/overview/types.ts | 5 + 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index b421520fd6aa..0c3882eab221 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, }; } @@ -237,7 +262,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 f28b84360392..2443debcc72c 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -195,6 +195,10 @@ describe('AutofixOverview', () => { expect(screen.getByText('+42')).toBeInTheDocument(); expect(screen.getByText('−7')).toBeInTheDocument(); + // 49 changed lines is over the inline-differ limit, 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(); @@ -614,6 +618,94 @@ describe('AutofixOverview', () => { expect(indicator).toHaveTextContent('3'); }); + 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('normalizes space-less • bullets into a markdown list', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 41651ac2a66c..ddedc85c8b2e 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -25,6 +25,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 {StepIndicator} from './stepIndicator'; @@ -325,6 +326,23 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { )} + {/* 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}) => ( + + ))} + + )} + {/* Bottom row: the analysis toggle stretches across the card's tail (whole row toggles), project provenance inline at the right, linking to its project page */} diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 171583b7721e..80a591ec0c4e 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'; @@ -77,6 +78,10 @@ export interface OverviewRow { // 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 From 79ad7528630234f141b88445cf2b1b1b8de12e8a Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 18:57:04 -0400 Subject: [PATCH 11/34] feat(seer): Project selector on the Autofix overview The canonical ProjectPageFilter (inside PageFiltersContainer), first in the filter toolbar ahead of the client-side selects. The selection replaces useAutofixIssues' hardcoded project:-1 on the issues request and the query gates on page-filters readiness; the runs/state fan-out follows the filtered group ids, so the whole page scopes with it. Changing projects resets the cursor. Co-Authored-By: Claude Fable 5 --- .../autofixIssuesDemo/useAutofixIssues.tsx | 11 +- .../seerWorkflows/overview/index.spec.tsx | 40 ++ .../views/seerWorkflows/overview/index.tsx | 361 +++++++++--------- 3 files changed, 240 insertions(+), 172 deletions(-) diff --git a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx index d4f958346898..7bc4d86ee9b9 100644 --- a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx +++ b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx @@ -136,6 +136,12 @@ 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; + // 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 +167,8 @@ interface UseAutofixIssuesResult { export function useAutofixIssues({ query, cursor, + enabled = true, + projects, questions = DEMO_QUESTIONS, runsQuery: runsQueryFilter = RUNS_QUERY, }: UseAutofixIssuesParams): UseAutofixIssuesResult { @@ -173,7 +181,7 @@ export function useAutofixIssues({ query: { query: withRequiredFilter(query ?? ''), cursor, - project: -1, + project: 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 +191,7 @@ export function useAutofixIssues({ }, staleTime: 30_000, }), + enabled, select: selectJsonWithHeaders, }); diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 2443debcc72c..c4c5d1e093c0 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'; @@ -88,6 +92,16 @@ describe('AutofixOverview', () => { beforeEach(() => { MockApiClient.clearMockResponses(); + // 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/`, body: [issue], @@ -618,6 +632,32 @@ describe('AutofixOverview', () => { expect(indicator).toHaveTextContent('3'); }); + 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], + }); + + 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/`, diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 1bb3a8d9b70f..eab46a53324d 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -12,6 +12,10 @@ import {Heading, Text} from '@sentry/scraps/text'; import Feature from 'sentry/components/acl/feature'; 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 {t} from 'sentry/locale'; @@ -104,9 +108,16 @@ export default function AutofixOverview() { const period = decodeScalar(location.query.period); const sort = (decodeScalar(location.query.sort) as SortValue | undefined) ?? 'triage'; + // 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, + projects: selection.projects, runsQuery: OVERVIEW_RUNS_QUERY, questions: RUN_QUESTION_PROMPTS, }); @@ -226,94 +237,101 @@ 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')} - + + + + + + {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')} - /> - - - - - - - updateQuery({ - outcome: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - {/* TODO(seer): "Triggered by" filter disabled until the runs + + + + toggleQuickFilter('awaiting_input')} + /> + toggleQuickFilter('code_changes_ready')} + /> + toggleQuickFilter('review_pr')} + /> + toggleQuickFilter('merged')} + /> + + + + + {/* Server-side scope first, then the client-side filters */} + + + + + + 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({ - attention: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - period: - selected.value === '' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - // Default sort keeps the URL clean. - sort: - selected.value === 'triage' - ? undefined - : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> + + updateQuery({ + attention: selected.map(o => String(o.value)), + }) + } + trigger={triggerProps => ( + + )} + /> + + updateQuery({ + period: + selected.value === '' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + updateQuery({ + // Default sort keeps the URL clean. + sort: + selected.value === 'triage' + ? undefined + : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + {hasActiveFilters ? ( + + ) : null} - {hasActiveFilters ? ( - - ) : null} - - - - {isError ? ( - - ) : isPending ? ( - - ) : sortedRows.length === 0 ? ( - - - {hasActiveFilters - ? t('No issues match your filters.') - : t('No completed autofix runs yet.')} - - ) : ( - - {sortedRows.map(({row}) => ( - - ))} - - )} - {!isPending && !isError && } - - - + {isError ? ( + + ) : isPending ? ( + + ) : sortedRows.length === 0 ? ( + + + {hasActiveFilters + ? t('No issues match your filters.') + : t('No completed autofix runs yet.')} + + + ) : ( + + {sortedRows.map(({row}) => ( + + ))} + + )} + + {!isPending && !isError && } + + + + ); } From 656dbf04d85fa5b35bc0714e9c7b4ea9c08f07f2 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 19:03:55 -0400 Subject: [PATCH 12/34] ref(seer): Drop the Workflow runs / Runs demo header buttons Co-Authored-By: Claude Fable 5 --- .../views/seerWorkflows/overview/index.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index eab46a53324d..18ce6150356d 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -2,7 +2,7 @@ import {useMemo} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; -import {Button, LinkButton} from '@sentry/scraps/button'; +import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; @@ -240,26 +240,14 @@ export default function AutofixOverview() { - - - {t('Autofix Overview')} - - {t( - 'Issues where Autofix has produced a root cause, solution, code changes, or pull request.' - )} - - - - - {t('Workflow runs')} - - - {t('Runs demo')} - - - + + {t('Autofix Overview')} + + {t( + 'Issues where Autofix has produced a root cause, solution, code changes, or pull request.' + )} + + Date: Mon, 20 Jul 2026 19:27:01 -0400 Subject: [PATCH 13/34] ref(seer): Adopt the standard top-bar chrome, drop width caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page title moves into the app's slim top bar via Layout.Title with the description as its info tip, matching the issue stream pages. The filters become an unboxed first row of the content (project selector + the client-side selects), stat cards follow, and the bordered toolbar container goes away. The 85% content cap and the 90ch caps inside cards are removed — neither came from the design system. Co-Authored-By: Claude Fable 5 --- .../views/seerWorkflows/overview/index.tsx | 313 +++++++++--------- .../seerWorkflows/overview/issueCard.tsx | 10 +- 2 files changed, 153 insertions(+), 170 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 18ce6150356d..b8c97b7f76e8 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -4,12 +4,14 @@ import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {InfoTip} from '@sentry/scraps/info'; import {Container, Flex, Grid, 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 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'; @@ -17,7 +19,7 @@ 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 {IconFix, IconMerge, IconPullRequest, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; @@ -239,87 +241,44 @@ export default function AutofixOverview() { > - - - {t('Autofix Overview')} - - {t( - 'Issues where Autofix has produced a root cause, solution, code changes, or pull request.' - )} - - - - - - toggleQuickFilter('awaiting_input')} - /> - toggleQuickFilter('code_changes_ready')} - /> - toggleQuickFilter('review_pr')} - /> - toggleQuickFilter('merged')} - /> - - - - - {/* Server-side scope first, then the client-side filters */} - - - - - - updateQuery({ - outcome: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} + {/* 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')} + + + + {/* Filters first, unboxed, matching the issue stream's layout: + server-side scope, then the client-side filters */} + + + + + + + updateQuery({ + outcome: selected.map(o => String(o.value)), + }) + } + trigger={triggerProps => ( + - {/* TODO(seer): "Triggered by" filter disabled until the runs + )} + /> + {/* TODO(seer): "Triggered by" filter disabled until the runs API exposes the autofix trigger (referrer/auto_run_source); see TRIGGER_FILTER_OPTIONS above. )} /> */} - - updateQuery({ - attention: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} + + updateQuery({ + attention: selected.map(o => String(o.value)), + }) + } + trigger={triggerProps => ( + - - updateQuery({ - period: - selected.value === '' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} + )} + /> + + updateQuery({ + period: selected.value === '' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + - - updateQuery({ - // Default sort keeps the URL clean. - sort: - selected.value === 'triage' - ? undefined - : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} + )} + /> + + updateQuery({ + // Default sort keeps the URL clean. + sort: + selected.value === 'triage' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + - - {hasActiveFilters ? ( - - ) : null} - + )} + /> + + {hasActiveFilters ? ( + + ) : null} + + + + toggleQuickFilter('awaiting_input')} + /> + toggleQuickFilter('code_changes_ready')} + /> + toggleQuickFilter('review_pr')} + /> + toggleQuickFilter('merged')} + /> + + + {isError ? ( + + ) : isPending ? ( + + ) : sortedRows.length === 0 ? ( + + + {hasActiveFilters + ? t('No issues match your filters.') + : t('No completed autofix runs yet.')} + - - {isError ? ( - - ) : isPending ? ( - - ) : sortedRows.length === 0 ? ( - - - {hasActiveFilters - ? t('No issues match your filters.') - : t('No completed autofix runs yet.')} - - - ) : ( - - {sortedRows.map(({row}) => ( - - ))} - - )} - - {!isPending && !isError && } - + ) : ( + + {sortedRows.map(({row}) => ( + + ))} + + )} + + {!isPending && !isError && } diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index ddedc85c8b2e..677ca9d90f38 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -301,13 +301,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { describe the same change twice), otherwise the diagnosis summary. Same anatomy for both; icon + label color tell them apart. */} {bodyEntry && ( - + {isFixBody ? ( @@ -330,7 +324,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { 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}) => ( Date: Mon, 20 Jul 2026 19:29:06 -0400 Subject: [PATCH 14/34] fix(seer): Keep long paths from clipping the diff pill tooltip Paths have no break points, so long ones overflowed the tooltip's max width and pushed the +/- counts past its clipped edge. Truncate paths from the left (rtl, like the diff viewer's file header) so the filename stays visible and the counts stay inside. Co-Authored-By: Claude Fable 5 --- .../views/seerWorkflows/overview/issueCard.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 677ca9d90f38..2bcacb626b51 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -55,6 +55,18 @@ const AnalysisToggle = styled(Button)` // 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}) { @@ -64,9 +76,9 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { {shown.map(file => ( - + {file.path} - + +{file.added} From f4e2b365b338638d154b34aba6a09144b2e734fe Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 19:55:53 -0400 Subject: [PATCH 15/34] feat(seer): Stage-hue ramp on the progress ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring's arc now colors by pipeline stage instead of run status — a single accent-scale ramp (gray → deepening blue) so arc length and intensity both say how far the run got, no legend needed. Urgency stays on the action button and the tooltip's status word; success green remains reserved for the merged checkmark. The steps-remaining digit follows the ramp. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index b91786d382e2..266c94e87e83 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -146,12 +146,6 @@ export function StepIndicator({row}: {row: OverviewRow}) { } const {variant, statusWord} = deriveStatus(row); - // Same variant → color resolution the icons use (svgIcon.tsx), so the - // ring and the step icon beside it always agree. - const ringColor = - variant === 'warning' - ? theme.tokens.graphics.warning.vibrant - : theme.tokens.content[variant === 'muted' ? 'secondary' : variant]; const furthest = row.outcomes.at(-1); // Index against the canonical order rather than counting outcomes: a // coding-agent run can skip the solution stage, so the array's length can @@ -163,6 +157,19 @@ export function StepIndicator({row}: {row: OverviewRow}) { const stepLabel = currentStep ? STEP_LABELS[currentStep] : undefined; + // Stage hue: one accent-scale ramp that deepens as the run advances, so + // arc length and color intensity say "how far" twice — no legend needed. + // Urgency deliberately stays off the ring (the action button and the + // tooltip's status word carry it); success green is reserved for merged. + const stageRingColors = [ + undefined, + theme.colors.gray400, + theme.colors.blue300, + theme.colors.blue400, + theme.colors.blue500, + ]; + const ringColor = stageRingColors[fill] ?? theme.tokens.content.secondary; + const ariaLabel = row.prMerged ? t('Autofix progress: 5 of 5 steps — PR merged') : stepLabel From cda2a0de3bb0ff07ea4c28880add13e965b05245 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 19:58:17 -0400 Subject: [PATCH 16/34] feat(seer): Traffic-light countdown ramp on the progress ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red when only the root cause exists, amber with a plan, greening as code and the PR land — matching the steps-until-fix framing so the ring reads instantly. Replaces the too-subtle gray-to-blue ramp. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 266c94e87e83..e845e4e291fc 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -157,16 +157,17 @@ export function StepIndicator({row}: {row: OverviewRow}) { const stepLabel = currentStep ? STEP_LABELS[currentStep] : undefined; - // Stage hue: one accent-scale ramp that deepens as the run advances, so - // arc length and color intensity say "how far" twice — no legend needed. - // Urgency deliberately stays off the ring (the action button and the - // tooltip's status word carry it); success green is reserved for merged. + // Stage hue: a traffic-light countdown toward the fix — red when the run + // has only diagnosed the issue, amber once a plan exists, greening as the + // code and PR land. Matches the "N steps until issue fix" framing (red = + // far, green = imminent) so the ramp reads instantly without a legend. + // Run urgency stays on the action button and the tooltip's status word. const stageRingColors = [ undefined, - theme.colors.gray400, - theme.colors.blue300, - theme.colors.blue400, - theme.colors.blue500, + theme.colors.red400, // root cause — fix still far off + theme.colors.yellow400, // plan in hand + theme.colors.green300, // code drafted — nearly there + theme.colors.green500, // PR opened — one step out ]; const ringColor = stageRingColors[fill] ?? theme.tokens.content.secondary; From 3513b8ff6e73dafdcbfea7f88f9e3102f39c1d19 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 19:59:59 -0400 Subject: [PATCH 17/34] fix(seer): Ramp the ring by urgency as the fix nears Heat now builds toward the finish: quiet gray at root cause, amber as a plan and code land, hottest at PR-opened where the human's review is the one step left, green checkmark once merged. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index e845e4e291fc..6f1824cdc00d 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -157,17 +157,17 @@ export function StepIndicator({row}: {row: OverviewRow}) { const stepLabel = currentStep ? STEP_LABELS[currentStep] : undefined; - // Stage hue: a traffic-light countdown toward the fix — red when the run - // has only diagnosed the issue, amber once a plan exists, greening as the - // code and PR land. Matches the "N steps until issue fix" framing (red = - // far, green = imminent) so the ramp reads instantly without a legend. - // Run urgency stays on the action button and the tooltip's status word. + // Stage hue: heat builds as the fix gets closer, because so does the + // urgency of the human's move — quiet gray while Seer is still diagnosing, + // warming through amber as a plan and code land, hottest at PR-opened + // (review and merge is the one step left), then the green checkmark once + // merged puts the fire out. const stageRingColors = [ undefined, - theme.colors.red400, // root cause — fix still far off - theme.colors.yellow400, // plan in hand - theme.colors.green300, // code drafted — nearly there - theme.colors.green500, // PR opened — one step out + theme.colors.gray400, // root cause — nothing to act on yet + theme.colors.yellow300, // plan in hand + theme.colors.yellow500, // code drafted — worth a look + theme.colors.red400, // PR opened — your move, one step from fixed ]; const ringColor = stageRingColors[fill] ?? theme.tokens.content.secondary; From 544242eacdb6a8a3f5388a7b7fc005c542699ec3 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 20:10:18 -0400 Subject: [PATCH 18/34] fix(seer): Match card typography to the issues feed and details pages Audited against the stream rows and details page: the card ran 1-2px small in three places. Title goes lg (16px, the feed's row-title size), the vitals subline goes sm (12px, the feed's metadata line), and fix/analysis prose goes md (14px, details-page body). Uppercase eyebrow labels stay xs per the codebase's label convention. Co-Authored-By: Claude Fable 5 --- .../views/seerWorkflows/overview/issueCard.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 2bcacb626b51..de5c9ad5ad0c 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -155,7 +155,8 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { 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 ? ( - + {eventCountLabel} {row.userCount > 0 && ` · ${userCountLabel}`} - + {'·'} - + + {t('Seer asked: %s', row.pendingQuestion)} )} @@ -325,7 +326,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {isFixBody ? t('Proposed fix') : t('Diagnosis')} - + @@ -435,7 +436,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { {section.label} - + From 7f3d2b254b695fed9e8feb559a4cb35a5f50a895 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 20:15:39 -0400 Subject: [PATCH 19/34] fix(seer): Return the ring to the deepening accent-purple ramp The heat ramp's red/amber read as "something is wrong" rather than "almost done". Back to gray-to-deepening-blurple as the run advances, keeping alarm colors reserved for the action buttons and the merged checkmark green as the terminal. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/stepIndicator.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/stepIndicator.tsx b/static/app/views/seerWorkflows/overview/stepIndicator.tsx index 6f1824cdc00d..3a7a9e9ae96d 100644 --- a/static/app/views/seerWorkflows/overview/stepIndicator.tsx +++ b/static/app/views/seerWorkflows/overview/stepIndicator.tsx @@ -157,17 +157,18 @@ export function StepIndicator({row}: {row: OverviewRow}) { const stepLabel = currentStep ? STEP_LABELS[currentStep] : undefined; - // Stage hue: heat builds as the fix gets closer, because so does the - // urgency of the human's move — quiet gray while Seer is still diagnosing, - // warming through amber as a plan and code land, hottest at PR-opened - // (review and merge is the one step left), then the green checkmark once - // merged puts the fire out. + // Stage hue: the accent purple deepens as the run advances — quiet gray at + // root cause, then light-to-dark blurple through plan, code, and PR. Arc + // length and intensity say "how far" twice, and staying out of the alarm + // palette keeps red/amber meaning "something needs you" on the action + // buttons rather than "something is wrong" on every card. Success green + // stays reserved for the merged checkmark. const stageRingColors = [ undefined, - theme.colors.gray400, // root cause — nothing to act on yet - theme.colors.yellow300, // plan in hand - theme.colors.yellow500, // code drafted — worth a look - theme.colors.red400, // PR opened — your move, one step from fixed + theme.colors.gray400, // root cause — just getting started + theme.colors.blue300, // plan in hand + theme.colors.blue400, // code drafted + theme.colors.blue500, // PR opened — deepest, one step from fixed ]; const ringColor = stageRingColors[fill] ?? theme.tokens.content.secondary; From 08df9dc9ffe2f78c0eb97d95e8b16730784469da Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 20:24:07 -0400 Subject: [PATCH 20/34] ref(seer): Remove the quick-filter stat cards The four stat cards and their whole apparatus go: the StatCard component, the ?quick= URL filter (the cards were its only entry point), the per-page counts, and the now-unused imports. The Needs attention select covers the same filtering. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 39 +---- .../views/seerWorkflows/overview/index.tsx | 143 +----------------- 2 files changed, 4 insertions(+), 178 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index c4c5d1e093c0..dc9ee21b971e 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -319,25 +319,6 @@ describe('AutofixOverview', () => { 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/`, @@ -414,7 +395,7 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); - 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: [ @@ -431,7 +412,7 @@ 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(); @@ -445,14 +426,6 @@ describe('AutofixOverview', () => { expect(indicator).not.toHaveTextContent(/\d/); await userEvent.hover(indicator); expect(await screen.findByText('Issue fixed')).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 () => { @@ -507,14 +480,6 @@ describe('AutofixOverview', () => { expect(titles).toEqual(['Issue B', 'Issue C', 'Issue A']); }); - it('always enables the Merged PRs card, showing 0 when nothing is merged', async () => { - renderPage(); - - const mergedCard = await screen.findByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('0'); - }); - 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/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index b8c97b7f76e8..f95ad45faba6 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -1,14 +1,13 @@ import {useMemo} from 'react'; -import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; -import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +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 {Text} from '@sentry/scraps/text'; import Feature from 'sentry/components/acl/feature'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -19,7 +18,6 @@ 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 {IconFix, IconMerge, IconPullRequest, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; @@ -73,8 +71,6 @@ 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'; const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ @@ -106,7 +102,6 @@ export default function AutofixOverview() { // 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'; @@ -134,10 +129,6 @@ 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; @@ -163,13 +154,6 @@ 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; } @@ -197,38 +181,15 @@ export default function AutofixOverview() { ); }); - 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 clearAllFilters = () => { updateQuery({ outcome: undefined, attention: undefined, - quick: undefined, period: undefined, }); }; @@ -357,41 +318,6 @@ export default function AutofixOverview() { ) : null} - - toggleQuickFilter('awaiting_input')} - /> - toggleQuickFilter('code_changes_ready')} - /> - toggleQuickFilter('review_pr')} - /> - toggleQuickFilter('merged')} - /> - - {isError ? ( ) : isPending ? ( @@ -431,68 +357,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} - - - - ); -} From 8c3469626221c99476a3ff8b77c69522a9f15780 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:17:05 -0400 Subject: [PATCH 21/34] feat(seer): Deep-link focus mode via ?id= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ?id= present the overview becomes a single-card permalink: the issue is fetched by group id (the endpoint pins exact ids in that mode and ignores list filters/pagination, and project scoping is widened so a stale selection can't hide the link's target), the card renders with Full analysis and inline diffs expanded, list chrome and pagination hide, and an All issues link clears the param while preserving the rest of the URL. No card affordance links here — the param is for externally shared links. Co-Authored-By: Claude Fable 5 --- .../autofixIssuesDemo/useAutofixIssues.tsx | 10 +- .../seerWorkflows/overview/index.spec.tsx | 78 +++++++ .../views/seerWorkflows/overview/index.tsx | 220 +++++++++++------- .../seerWorkflows/overview/issueCard.tsx | 15 +- 4 files changed, 230 insertions(+), 93 deletions(-) diff --git a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx index 7bc4d86ee9b9..7260ebb31b40 100644 --- a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx +++ b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx @@ -139,6 +139,10 @@ interface UseAutofixIssuesParams { // 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[]; @@ -168,6 +172,7 @@ export function useAutofixIssues({ query, cursor, enabled = true, + groupIds: pinnedGroupIds, projects, questions = DEMO_QUESTIONS, runsQuery: runsQueryFilter = RUNS_QUERY, @@ -181,7 +186,10 @@ export function useAutofixIssues({ query: { query: withRequiredFilter(query ?? ''), cursor, - project: projects ?? -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 diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index dc9ee21b971e..007e5aa9e4f1 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -711,6 +711,84 @@ describe('AutofixOverview', () => { 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('normalizes space-less • bullets into a markdown list', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index f95ad45faba6..11c9dc04ee2a 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -1,7 +1,7 @@ import {useMemo} from 'react'; import {Alert} from '@sentry/scraps/alert'; -import {Button} from '@sentry/scraps/button'; +import {Button, LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -18,6 +18,7 @@ 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 {IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; @@ -98,6 +99,10 @@ 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[]; @@ -114,6 +119,7 @@ export default function AutofixOverview() { query: '', cursor, enabled: pageFiltersReady, + groupIds: selectedId ? [selectedId] : undefined, projects: selection.projects, runsQuery: OVERVIEW_RUNS_QUERY, questions: RUN_QUESTION_PROMPTS, @@ -181,6 +187,10 @@ export default function AutofixOverview() { ); }); + // Focus mode shows the fetched issue as-is — client-side filters and sort + // don't apply to a single deep-linked card. + const visibleRows = selectedId ? rowsWithAttention : sortedRows; + const hasActiveFilters = outcomeFilter.length > 0 || attentionFilter.length > 0 || @@ -215,31 +225,48 @@ export default function AutofixOverview() { /> - {/* Filters first, unboxed, matching the issue stream's layout: - server-side scope, then the client-side filters */} - - - - - - - updateQuery({ - outcome: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - {/* TODO(seer): "Triggered by" filter disabled until the runs + {/* 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 + + + + + + + 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({ - attention: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - period: selected.value === '' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - // Default sort keeps the URL clean. - sort: - selected.value === 'triage' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> + + updateQuery({ + attention: selected.map(o => String(o.value)), + }) + } + trigger={triggerProps => ( + + )} + /> + + updateQuery({ + period: + selected.value === '' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + updateQuery({ + // Default sort keeps the URL clean. + sort: + selected.value === 'triage' + ? undefined + : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + {hasActiveFilters ? ( + + ) : null} - {hasActiveFilters ? ( - - ) : null} - + )} {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.')} ) : ( - {sortedRows.map(({row}) => ( - + {visibleRows.map(({row}) => ( + ))} )} - {!isPending && !isError && } + {!selectedId && !isPending && !isError && ( + + )} diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index de5c9ad5ad0c..905fb2e997fe 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -114,7 +114,17 @@ function FixabilityTag({score}: {score: number}) { ); } -export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { +export function IssueCard({ + orgSlug, + row, + defaultExpanded = false, +}: { + orgSlug: string; + row: OverviewRow; + // Open every collapsible (Full analysis, 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). @@ -128,7 +138,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { const bodyEntry = proposedFix ?? summary; const isFixBody = bodyEntry?.key === 'fix_summary'; const detailEntries = row.analysis.filter(entry => entry.placement === 'details'); - const [analysisExpanded, setAnalysisExpanded] = useState(false); + const [analysisExpanded, setAnalysisExpanded] = useState(defaultExpanded); const analysisId = useId(); const eventCountLabel = @@ -344,6 +354,7 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { patch={patch} repoName={repoName} collapsible + defaultExpanded={defaultExpanded} showBorder /> ))} From 1f77665d3dfb9d3bfcb516d3416576852a8a76ea Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:19:43 -0400 Subject: [PATCH 22/34] ref(seer): Remove the fixability tag from overview cards The FixabilityTag component, the row's fixabilityScore field, its derivation from the issue serializer, and the spec assertions all go with it. The identity strip keeps level + short id. Co-Authored-By: Claude Fable 5 --- .../overview/buildOverviewRows.ts | 1 - .../seerWorkflows/overview/index.spec.tsx | 3 --- .../seerWorkflows/overview/issueCard.tsx | 24 ++----------------- .../app/views/seerWorkflows/overview/types.ts | 1 - 4 files changed, 2 insertions(+), 27 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index 0c3882eab221..ac6449a05fb5 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -248,7 +248,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 ?? diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 007e5aa9e4f1..017ea75d5d24 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -22,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. @@ -263,8 +262,6 @@ describe('AutofixOverview', () => { ).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(); // The issue level moved off the header into the identity strip — exactly // one occurrence (ErrorLevel's visually-hidden label). expect(screen.getAllByText('Level: Warning')).toHaveLength(1); diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 905fb2e997fe..09e0962d2e97 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -98,22 +98,6 @@ 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, @@ -394,17 +378,13 @@ export function IssueCard({ card width */} {analysisExpanded && detailEntries.length > 0 && ( - {/* Compact identity strip: the level, short id, and Seer's - fixability read — the raw title lives in the headline - tooltip, not here */} + {/* Compact identity strip: the level and short id — 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 diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 80a591ec0c4e..87a011a07a5a 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -74,7 +74,6 @@ 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; From 1488b2165dda7950aa0ac58b9cabb23f947b8815 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:37:39 -0400 Subject: [PATCH 23/34] feat(seer): Promote Next steps to its own body block on the card face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no code was drafted, the reviewer notes ARE the human's to-do — they now render as their own block under the Diagnosis instead of hiding in the collapsed analysis. Same anatomy as its siblings, but in the accent voice with an accent left edge (the one block asking the reader to act) and the markdown bullets restyled into an arrow to-do rail. With a drafted fix, the notes stay in the details as the Review checklist as before. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 12 +-- .../seerWorkflows/overview/issueCard.tsx | 101 +++++++++++++----- 2 files changed, 83 insertions(+), 30 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 017ea75d5d24..6ab76edec9a2 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -308,9 +308,8 @@ describe('AutofixOverview', () => { ).toBeVisible(); 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. + // …and the notes are promoted onto the face as their own Next-steps + // block — no expansion needed, and no Review checklist anywhere. expect(screen.getByText('Next steps')).toBeVisible(); expect(screen.getByText('Decide whether Seer should generate a fix.')).toBeVisible(); expect(screen.queryByText('Review checklist')).not.toBeInTheDocument(); @@ -811,11 +810,12 @@ describe('AutofixOverview', () => { renderPage(); - await userEvent.click(await screen.findByRole('button', {name: 'Full analysis'})); - - expect(screen.getByText('Confirm the header is not leaked.')).toBeVisible(); + // The notes render on the face as the Next-steps block (no drafted fix), + // so there is nothing left behind a Full analysis toggle. + expect(await screen.findByText('Confirm the header is not leaked.')).toBeVisible(); expect(screen.getByText('Verify both headers work.')).toBeVisible(); expect(screen.queryByText(/•/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Full analysis'})).not.toBeInTheDocument(); }); it('renders an error state and can retry', async () => { diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 09e0962d2e97..4c4c90a13337 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -40,6 +40,31 @@ const TitleLink = styled(Link)` } `; +// Next-steps answers are markdown action lists; restyle the bullets into an +// accent to-do rail. List markers aren't reachable through the design +// system's primitives, hence the styled wrapper — the content itself still +// renders through SeerMarkdown so inline code and links keep working. +const NextStepsList = styled('div')` + ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: ${p => p.theme.space.xs}; + } + li { + position: relative; + padding-left: ${p => p.theme.space.xl}; + } + li::before { + content: '→'; + position: absolute; + left: 0; + color: ${p => p.theme.tokens.content.accent}; + } +`; + // The "Full analysis" trigger stretches across its row so the whole tail of // the card is a click target (label pinned left), shedding the Button's // horizontal padding to sit flush with the rows above. (Not core Disclosure: @@ -114,14 +139,22 @@ export function IssueCard({ // 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 + // The body leads with 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 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'); + // Without drafted code, the reviewer notes ARE the human's to-do — promote + // them onto the card face as their own block. With a fix they stay in the + // details as the review checklist (the fix body dominates that card). + const nextSteps = isFixBody + ? undefined + : row.analysis.find(entry => entry.key === 'reviewer_notes'); + const detailEntries = row.analysis.filter( + entry => entry.placement === 'details' && entry !== nextSteps + ); const [analysisExpanded, setAnalysisExpanded] = useState(defaultExpanded); const analysisId = useId(); @@ -303,10 +336,11 @@ export function IssueCard({ )} - {/* 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. */} + {/* The body leads with 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 && ( @@ -327,6 +361,34 @@ export function IssueCard({ )} + {/* The human's to-do, promoted onto the face when no code was + drafted: same block anatomy as the diagnosis, accent voice and + edge because this is the one block asking the reader to act, and + the bullets restyled into a to-do rail. */} + {nextSteps && ( + + + + + + {t('Next steps')} + + + + + + + + + + )} + {/* 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 */} @@ -395,26 +457,17 @@ export function IssueCard({ align="start" > {detailEntries.map(entry => { + // reviewer_notes only reaches the details when code was + // drafted — the no-code case is promoted onto the card face + // as the Next-steps block. const section = entry.key === 'reviewer_notes' - ? isFixBody - ? { - label: t('Review checklist'), - icon: ( - - ), - } - : { - label: t('Next steps'), - icon: ( - - ), - } + ? { + label: t('Review checklist'), + icon: ( + + ), + } : { label: entry.label, icon: , From 81a2c6f6f0ee595f93553ae52db12797e3251c65 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:39:37 -0400 Subject: [PATCH 24/34] fix(seer): Quiet the Next-steps block The accent edge + accent label + custom arrow markers read as too much, and the marker CSS fought the markdown renderer's own bullets (double markers). Now the same muted anatomy as the Diagnosis block with plain markdown bullets. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/issueCard.tsx | 49 +++---------------- 1 file changed, 7 insertions(+), 42 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 4c4c90a13337..488fe363ab70 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -40,31 +40,6 @@ const TitleLink = styled(Link)` } `; -// Next-steps answers are markdown action lists; restyle the bullets into an -// accent to-do rail. List markers aren't reachable through the design -// system's primitives, hence the styled wrapper — the content itself still -// renders through SeerMarkdown so inline code and links keep working. -const NextStepsList = styled('div')` - ul { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xs}; - } - li { - position: relative; - padding-left: ${p => p.theme.space.xl}; - } - li::before { - content: '→'; - position: absolute; - left: 0; - color: ${p => p.theme.tokens.content.accent}; - } -`; - // The "Full analysis" trigger stretches across its row so the whole tail of // the card is a click target (label pinned left), shedding the Button's // horizontal padding to sit flush with the rows above. (Not core Disclosure: @@ -362,29 +337,19 @@ export function IssueCard({ )} {/* The human's to-do, promoted onto the face when no code was - drafted: same block anatomy as the diagnosis, accent voice and - edge because this is the one block asking the reader to act, and - the bullets restyled into a to-do rail. */} + drafted — same quiet anatomy as the diagnosis block above it. */} {nextSteps && ( - + - - + + {t('Next steps')} - - - - - + + + )} From 1728dfc6556b3b082fed9cbea241e9178a72e391 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:42:20 -0400 Subject: [PATCH 25/34] fix(seer): Size the card CTAs like the rest of the app's controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header actions (Review PR / Open PR / Generate code / View run / Retry / PR link) step up from the 24px zero size to sm (32px, 14px font) — the same size as this page's filter triggers and the standard in-content action size. Co-Authored-By: Claude Fable 5 --- .../app/views/seerWorkflows/overview/attentionBadge.tsx | 8 ++++---- static/app/views/seerWorkflows/overview/issueCard.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx index 3d318f8569cb..6e72ef90283a 100644 --- a/static/app/views/seerWorkflows/overview/attentionBadge.tsx +++ b/static/app/views/seerWorkflows/overview/attentionBadge.tsx @@ -163,7 +163,7 @@ export function AttentionBadge({ if (reason === 'code_changes_ready') { return ( - } to={to}> + } to={to}> {meta.label} @@ -172,7 +172,7 @@ export function AttentionBadge({ if (reason === 'solution_ready') { return ( - } to={to}> + } to={to}> {meta.label} @@ -181,7 +181,7 @@ export function AttentionBadge({ if (reason === 'errored') { return ( - } to={to}> + } to={to}> {meta.label} @@ -190,7 +190,7 @@ export function AttentionBadge({ return ( - } to={to}> + } to={to}> {meta.label} diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 488fe363ab70..731397954f24 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -272,7 +272,7 @@ export function IssueCard({ skipWrapper > } href={row.prUrl} @@ -285,14 +285,14 @@ export function IssueCard({ ) : ( - + {t('View run')} )} {row.prUrl && attention !== 'review_pr' && ( } href={row.prUrl} From f6efff54221c1775071fafee0a86fbf4e456b827 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:44:52 -0400 Subject: [PATCH 26/34] fix(seer): Keep 'have Seer generate the fix' out of Next steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer-notes prompt suggested it as a step, so every list ended with one — but that action IS the card's Generate code button. The prompt now forbids it and asks for what the fix should do instead. Prompt edits self-invalidate the answer cache, so lists regenerate on next load. Co-Authored-By: Claude Fable 5 --- .../views/seerWorkflows/overview/runQuestions.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/runQuestions.tsx b/static/app/views/seerWorkflows/overview/runQuestions.tsx index 6ffa35460f5f..6245ae1f97d3 100644 --- a/static/app/views/seerWorkflows/overview/runQuestions.tsx +++ b/static/app/views/seerWorkflows/overview/runQuestions.tsx @@ -76,11 +76,15 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ '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.', + 'confirm in the codebase, what decision to make, what to investigate), ' + + 'concrete and specific to this issue, never generic advice. Never ' + + 'include a step telling 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, or omit that step. 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.', }, ]; From af62a1dabc263e8a2dbb5cd011ae3b7d612d6478 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:46:32 -0400 Subject: [PATCH 27/34] fix(seer): Cap Next steps at three bullets Only the highest-leverage steps make the card. Co-Authored-By: Claude Fable 5 --- static/app/views/seerWorkflows/overview/runQuestions.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/runQuestions.tsx b/static/app/views/seerWorkflows/overview/runQuestions.tsx index 6245ae1f97d3..e87fcacdbf2d 100644 --- a/static/app/views/seerWorkflows/overview/runQuestions.tsx +++ b/static/app/views/seerWorkflows/overview/runQuestions.tsx @@ -74,8 +74,9 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ '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 ' + + 'bullet). If the run produced no code: next steps instead — at most ' + + 'three markdown bullets, only the highest-leverage ones, on how an ' + + 'engineer should take this forward (what to ' + 'confirm in the codebase, what decision to make, what to investigate), ' + 'concrete and specific to this issue, never generic advice. Never ' + 'include a step telling the reader to have Seer or an AI generate the ' + From f7bbc66a76942339f02b5bf012157ff8a5d50d47 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Mon, 20 Jul 2026 23:53:34 -0400 Subject: [PATCH 28/34] ref(seer): Rename the analysis toggle to More details Co-Authored-By: Claude Fable 5 --- static/app/views/seerWorkflows/overview/index.spec.tsx | 6 +++--- static/app/views/seerWorkflows/overview/issueCard.tsx | 6 +++--- static/app/views/seerWorkflows/overview/types.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 6ab76edec9a2..894ae1f7a00e 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -240,7 +240,7 @@ describe('AutofixOverview', () => { // Root cause, notes, and the short id stay behind the analysis toggle, // which renders its block only when expanded. - const disclosure = screen.getByRole('button', {name: 'Full analysis'}); + const disclosure = screen.getByRole('button', {name: 'More details'}); expect( screen.queryByText('Commit c5bb895 stopped sending the Authorization header.') ).not.toBeInTheDocument(); @@ -811,11 +811,11 @@ describe('AutofixOverview', () => { renderPage(); // The notes render on the face as the Next-steps block (no drafted fix), - // so there is nothing left behind a Full analysis toggle. + // so there is nothing left behind a More-details toggle. expect(await screen.findByText('Confirm the header is not leaked.')).toBeVisible(); expect(screen.getByText('Verify both headers work.')).toBeVisible(); expect(screen.queryByText(/•/)).not.toBeInTheDocument(); - expect(screen.queryByRole('button', {name: 'Full analysis'})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'More details'})).not.toBeInTheDocument(); }); it('renders an error state and can retry', async () => { diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 731397954f24..1656cae3c9dc 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -40,7 +40,7 @@ const TitleLink = styled(Link)` } `; -// The "Full analysis" trigger stretches across its row so the whole tail of +// The "More details" trigger stretches across its row so the whole tail of // the card is a click target (label pinned left), shedding the Button's // horizontal padding to sit flush with the rows above. (Not core Disclosure: // the trigger anchors the bottom row while the analysis expands as a @@ -105,7 +105,7 @@ export function IssueCard({ }: { orgSlug: string; row: OverviewRow; - // Open every collapsible (Full analysis, inline diffs) on mount — the + // Open every collapsible (More details, inline diffs) on mount — the // overview's ?id= focus mode wants the whole card readable at once. defaultExpanded?: boolean; }) { @@ -391,7 +391,7 @@ export function IssueCard({ aria-controls={analysisId} onClick={() => setAnalysisExpanded(expanded => !expanded)} > - {t('Full analysis')} + {t('More details')} )} diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 87a011a07a5a..1de959c9af51 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -26,7 +26,7 @@ export type AttentionReason = | 'errored'; // Where an answered run question renders on the card: on the face (always -// visible) or inside the collapsed "Full analysis" disclosure. +// visible) or inside the collapsed "More details" disclosure. export type AnswerPlacement = 'face' | 'details'; // One answered run question joined to its question config From d4c89a4bf9bdca4ebb5ba61d1404257917a5588d Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Tue, 21 Jul 2026 00:07:40 -0400 Subject: [PATCH 29/34] ref(seer): Reset the cursor on filter changes, consolidate the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateQuery now clears the pagination cursor — every caller is a filter or sort change where a stale page makes no sense (the project filter already did this via resetParamsOnChange). The spec drops two overlapping tests: the running-indicator check folds into the triage- ordering test (whose third card is already an in-flight run) and the bullet-normalization check folds into the no-code-drafted test; a half-wrong comment about the inline-differ gate now names both disqualifiers. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 90 ++++--------------- .../views/seerWorkflows/overview/index.tsx | 5 +- 2 files changed, 23 insertions(+), 72 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 894ae1f7a00e..4ef7ba5109cb 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -200,7 +200,6 @@ describe('AutofixOverview', () => { await userEvent.hover(indicator); expect(await screen.findByText('1 step until issue fix')).toBeInTheDocument(); expect(screen.getByText('✓ Root cause')).toBeInTheDocument(); - expect(screen.getByText('✓ PR opened')).toBeInTheDocument(); expect(screen.getByText('○ Merged')).toBeInTheDocument(); // Exact patch stats from merged_file_patches, not an LLM estimate. @@ -208,8 +207,9 @@ describe('AutofixOverview', () => { expect(screen.getByText('+42')).toBeInTheDocument(); expect(screen.getByText('−7')).toBeInTheDocument(); - // 49 changed lines is over the inline-differ limit, so the file path - // lives only in the pill's hover tooltip — no diff header on the card. + // 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. @@ -287,7 +287,8 @@ describe('AutofixOverview', () => { { key: 'user_3', question: RUN_QUESTIONS[3]!.prompt, - answer: '- Decide whether Seer should generate a fix.', + // Space-less "•" bullets — the normalizer must split them. + answer: '•Decide whether to relax the constraint. •Verify the consumers.', }, ], }, @@ -309,10 +310,15 @@ describe('AutofixOverview', () => { expect(screen.queryByText('Proposed fix')).not.toBeInTheDocument(); // …and the notes are promoted onto the face as their own Next-steps - // block — no expansion needed, and no Review checklist anywhere. + // block — no expansion needed, no Review checklist anywhere, and the + // inline "•" bullets normalized into separate list items. With nothing + // left for the details, the More-details toggle doesn't render at all. expect(screen.getByText('Next steps')).toBeVisible(); - expect(screen.getByText('Decide whether Seer should generate a fix.')).toBeVisible(); + expect(screen.getByText('Decide whether to relax the constraint.')).toBeVisible(); + expect(screen.getByText('Verify the consumers.')).toBeVisible(); + expect(screen.queryByText(/•/)).not.toBeInTheDocument(); expect(screen.queryByText('Review checklist')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'More details'})).not.toBeInTheDocument(); }); it('applies the outcome filter with AND semantics', async () => { @@ -474,6 +480,13 @@ describe('AutofixOverview', () => { .map(link => link.textContent) .filter(text => text === 'Issue A' || text === 'Issue B' || text === 'Issue C'); expect(titles).toEqual(['Issue B', 'Issue C', 'Issue A']); + + // Issue C is in flight — its ring wears the running status word. + expect( + screen.getByRole('img', { + name: 'Autofix progress: 3 of 5 steps — Code changes (Running)', + }) + ).toBeInTheDocument(); }); it('surfaces the blocking question when a run awaits user input', async () => { @@ -521,38 +534,6 @@ describe('AutofixOverview', () => { ).toBeInTheDocument(); }); - it('shows a running step indicator for an in-flight run', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: { - autofix: { - run_id: 1, - status: 'processing', - 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(); - - expect( - await screen.findByRole('img', { - name: 'Autofix progress: 1 of 5 steps — Root cause (Running)', - }) - ).toBeInTheDocument(); - }); - it('shows an errored step indicator at the step the run died on', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, @@ -785,39 +766,6 @@ describe('AutofixOverview', () => { expect(backLink).toHaveAttribute('href', expect.not.stringContaining('id=2')); }); - it('normalizes space-less • bullets into a markdown list', 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: [ - { - 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.', - }, - ], - }, - ], - }); - - renderPage(); - - // The notes render on the face as the Next-steps block (no drafted fix), - // so there is nothing left behind a More-details toggle. - expect(await screen.findByText('Confirm the header is not leaked.')).toBeVisible(); - expect(screen.getByText('Verify both headers work.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); - expect(screen.queryByRole('button', {name: 'More details'})).not.toBeInTheDocument(); - }); - it('renders an error state and can retry', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 11c9dc04ee2a..4869a211fb6f 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -129,7 +129,10 @@ 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} ); From 02e82faa05abc740e4e24ebaa2005927d89e604f Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Tue, 21 Jul 2026 15:05:36 -0400 Subject: [PATCH 30/34] feat(seer): Linear-style collapsible sticky status groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards now live under sticky, collapsible sections — the triage tiers made visible: Needs your input, Awaiting your review, Code changes ready, Ready to generate code, Errored, Needs investigation (settled diagnosis-only runs whose next steps are manual — no pipeline button does not mean nothing to do), Running, Merged. Headers (icon + label + count) park below the top bar while their group scrolls and are pushed off by the next section; collapse state persists per user in localStorage; empty groups don't render. The Sort select now orders within groups (Needs you first retired — the group order carries it, so getTriageRank goes too). Focus mode stays ungrouped. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/attentionBadge.tsx | 34 ----- .../seerWorkflows/overview/index.spec.tsx | 31 +++- .../views/seerWorkflows/overview/index.tsx | 133 ++++++++++++++---- .../seerWorkflows/overview/statusGroups.tsx | 78 ++++++++++ 4 files changed, 208 insertions(+), 68 deletions(-) create mode 100644 static/app/views/seerWorkflows/overview/statusGroups.tsx diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx index 6e72ef90283a..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}; diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 4ef7ba5109cb..d42267bd76d4 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -90,6 +90,8 @@ 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 @@ -416,8 +418,9 @@ describe('AutofixOverview', () => { 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 step indicator renders its terminal all-success state: checkmark @@ -430,7 +433,7 @@ describe('AutofixOverview', () => { expect(await screen.findByText('Issue fixed')).toBeInTheDocument(); }); - 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/`, @@ -474,7 +477,22 @@ 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) @@ -487,6 +505,11 @@ describe('AutofixOverview', () => { name: 'Autofix progress: 3 of 5 steps — Code changes (Running)', }) ).toBeInTheDocument(); + + // 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(); }); it('surfaces the blocking question when a run awaits user input', async () => { diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 4869a211fb6f..379fc5b2235d 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -1,8 +1,11 @@ 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 {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'; @@ -18,23 +21,26 @@ 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 {Sticky} from 'sentry/components/sticky'; import {IconArrow} 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 {RUN_QUESTION_PROMPTS} from './runQuestions'; +import { + getStatusGroup, + STATUS_GROUP_META, + STATUS_GROUP_ORDER, + type StatusGroupKey, +} from './statusGroups'; import type {AttentionReason, AutofixOutcome} from './types'; // Only autofix runs. `source` is the run's origin surface (autofix, chat, @@ -72,10 +78,11 @@ const ATTENTION_FILTER_OPTIONS: Array<{ label: ATTENTION_META[value].label, })); -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'; 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')}, ]; @@ -108,7 +115,8 @@ export default function AutofixOverview() { // const triggerFilter = decodeList(location.query.trigger) as AutofixTrigger[]; const attentionFilter = decodeList(location.query.attention) as AttentionReason[]; 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 @@ -169,31 +177,45 @@ export default function AutofixOverview() { 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) + ); - // Focus mode shows the fetched issue as-is — client-side filters and sort - // don't apply to a single deep-linked card. + // 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 toggleGroup = (groupKey: StatusGroupKey, expanded: boolean) => { + setCollapsedGroups(previous => + expanded + ? previous.filter(key => key !== groupKey) + : [...previous.filter(key => key !== groupKey), groupKey] + ); + }; + const hasActiveFilters = outcomeFilter.length > 0 || attentionFilter.length > 0 || @@ -330,7 +352,7 @@ export default function AutofixOverview() { updateQuery({ // Default sort keeps the URL clean. sort: - selected.value === 'triage' + selected.value === 'activity' ? undefined : String(selected.value), }) @@ -366,17 +388,52 @@ export default function AutofixOverview() { : t('No completed autofix runs yet.')} - ) : ( + ) : selectedId ? ( {visibleRows.map(({row}) => ( ))} + ) : ( + + {groupedRows.map(([groupKey, rows]) => { + const meta = STATUS_GROUP_META[groupKey]; + return ( + toggleGroup(groupKey, next)} + > + + + + + {meta.label} + {rows.length} + + + + + + {rows.map(({row}) => ( + + ))} + + + + ); + })} + )} {!selectedId && !isPending && !isError && ( @@ -389,6 +446,22 @@ export default function AutofixOverview() { ); } +// 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 ( diff --git a/static/app/views/seerWorkflows/overview/statusGroups.tsx b/static/app/views/seerWorkflows/overview/statusGroups.tsx new file mode 100644 index 000000000000..4bccc8ce7b51 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/statusGroups.tsx @@ -0,0 +1,78 @@ +import { + IconCode, + IconCommit, + IconMerge, + IconPullRequest, + IconSearch, + IconSeer, + IconUser, + IconWarning, +} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {t} 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', +]; + +export const STATUS_GROUP_META: Record< + StatusGroupKey, + {Icon: React.ComponentType; label: string} +> = { + awaiting_input: {Icon: IconUser, label: t('Needs your input')}, + review_pr: {Icon: IconPullRequest, label: t('Awaiting your review')}, + code_changes_ready: {Icon: IconCommit, label: t('Code changes ready')}, + solution_ready: {Icon: IconCode, label: t('Ready to generate code')}, + errored: {Icon: IconWarning, label: t('Errored')}, + // 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')}, + running: {Icon: IconSeer, label: t('Running')}, + merged: {Icon: IconMerge, label: t('Merged')}, +}; + +/** + * 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'; +} From 504162bde3805df648e9eaec8b60d91c7f7e7700 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Tue, 21 Jul 2026 15:10:36 -0400 Subject: [PATCH 31/34] feat(seer): Subtle bulk expand/collapse for the status groups One quiet link-variant button on the filter row's right (beside Clear all): Collapse all folds every visible group, then reads Expand all to undo. State flows through the same persisted collapsed-groups list. Co-Authored-By: Claude Fable 5 --- .../seerWorkflows/overview/index.spec.tsx | 6 ++++ .../views/seerWorkflows/overview/index.tsx | 30 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index d42267bd76d4..398645bd9e79 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -510,6 +510,12 @@ describe('AutofixOverview', () => { await userEvent.click(mergedHeader); expect(screen.queryByRole('link', {name: 'Issue A'})).not.toBeInTheDocument(); expect(screen.getByRole('link', {name: 'Issue B'})).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: '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 () => { diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 379fc5b2235d..652133c01ab4 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -215,6 +215,9 @@ export default function AutofixOverview() { : [...previous.filter(key => key !== groupKey), groupKey] ); }; + const allGroupsCollapsed = + groupedRows.length > 0 && + groupedRows.every(([groupKey]) => collapsedGroups.includes(groupKey)); const hasActiveFilters = outcomeFilter.length > 0 || @@ -366,11 +369,28 @@ export default function AutofixOverview() { )} /> - {hasActiveFilters ? ( - - ) : null} + + {hasActiveFilters ? ( + + ) : null} + {groupedRows.length > 0 && ( + + )} + )} From defe9509adb42b13605a58f8944cabbfc6909f36 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Tue, 21 Jul 2026 15:15:29 -0400 Subject: [PATCH 32/34] fix(seer): Double-chevron icon on the bulk group toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IconChevron isDouble — up while collapsing is on offer, down once everything is folded and the button reads Expand all. Co-Authored-By: Claude Fable 5 --- static/app/views/seerWorkflows/overview/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 652133c01ab4..3dcecf2a62a2 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -22,7 +22,7 @@ import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPa import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {Sticky} from 'sentry/components/sticky'; -import {IconArrow} from 'sentry/icons'; +import {IconArrow, IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; @@ -379,6 +379,13 @@ export default function AutofixOverview() { )} + + 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')} + /> + )} @@ -433,7 +458,7 @@ export default function AutofixOverview() { {groupedRows.map(([groupKey, rows]) => { const meta = STATUS_GROUP_META[groupKey]; return ( - - - {rows.map(({row}) => ( - - ))} - + {view === 'cards' ? ( + + {rows.map(({row}) => ( + + ))} + + ) : ( + + {rows.map(({row}, index) => ( + + ))} + + )} - + ); })} @@ -483,6 +521,17 @@ export default function AutofixOverview() { ); } +// 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 diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index ba25d15b6af0..21448ad17cb1 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -281,47 +281,7 @@ export function IssueCard({ right, the project linking to its page */} - {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' && ( ); } + +/** + * 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')} + + + ); +}