feat(investigations): Add the hypothesis row for agentic runs - #124086
Conversation
Renders the hypotheses an agentic investigation is weighing as a row of cards: the statement, where it landed, the confidence behind that, and the checks the agent ran to get there. The data already exists. Seer pushes the whole live state of a run as one projection blob on every orchestration event, Sentry stores it on InvestigationOrchestrationRun.projection, and `/investigations/$id/orchestration/` serves the latest one. So the agent decides what these cards say purely by what it writes into `projection.hypotheses` -- there is no separate signal telling the frontend to render a hypothesis, and no new block kind. Decisions travel back as versioned commands fenced on workflowVersion, so one made against a stale view is rejected rather than applied to a run that has moved on. The row reflows on its container rather than the viewport: auto-fit over minmax tracks drops columns whenever the available space stops fitting another readable card. That lets the same component sit in a full-width detail view and in a narrow drawer without a breakpoint prop, and it is why this does not draw the connecting edges the flow-graph mock has. Types and API signatures match the ones in the in-flight orchestration branch (#122949) so the two collapse into one rather than conflicting. Stories cover the row, its reflow, every status, and the connected component running against the existing story fixture API, which now serves the two orchestration routes and applies commands in memory so accept, reject, and retry stay interactive on the stories page. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Story previewsPreview the stories changed in this PR on the Vercel deployment:
Preview deployment: https://sentry-c5itun4h1.sentry.dev |
📊 Type Coverage Diff
🔍 1 new type safety issue introducedType assertions (
This is informational only and does not block the PR. |
Four fixes from design review of the hypothesis row. The evidence list is a `ul` nested inside the row's own `ul`, so browsers gave it `list-style-type: circle` and drew a marker beside every step, sitting in the card's padding. Both lists now clear their markers. The card border carries the verdict rather than only marking the report's primary hypothesis: accent for supported or user-accepted, dotted while inconclusive, ordinary otherwise -- refuted included, since ruling something out is a result rather than a fault. `getBorder` only ever emits `1px solid`, so the dotted case is CSS; the colors are still border tokens. It is driven by a data attribute, which also makes it assertable, unlike emotion styles, which this repo's stubbed getComputedStyle cannot see. Stories now sit inside `Storybook.Demo`. That excludes their headings from the page's table of contents, which was listing every hypothesis statement and, because the same three statements repeat across stories, giving several entries the same id and marking them all active at once. The demo also supplies the container query context these cards size against. The three hardcoded-width reflow stories are gone; `Demo resizable` gives a drag handle and a live breakpoint readout instead. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
A dotted hairline is barely visible against the default border color, so the broken edge is dashed now. It also applies more widely. The border had three states; it has two. A solid accent edge marks the explanation that stands -- supported by the evidence, or accepted by a person -- and every other card is dashed. Investigating, refuted, inconclusive, cancelled and failed all mean the same thing to someone scanning the row: not the answer. The status line already carries which of them it is, so the border does not need to. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Knip flagged fourteen exports nothing outside their own module reads. Thirteen were module internals -- the status vocabulary behind `HypothesisStatus`, the polling predicate behind the orchestration query, and the projection types composed into `InvestigationOrchestration` -- so they lose the `export` keyword and keep working. Anything that needs one later can export it then. `isInvestigationOrchestrationConflictError` was speculative: added to match the in-flight orchestration branch, called by nothing here. Deleted; it arrives with the hook that uses it. `knip --production` separately reports the hypothesis components as unreachable, which is accurate -- no investigation surface renders the row yet, only stories and tests do. That is the same situation the config already records for `autofixChatContext` and the chat blocks, so it gets the same treatment: one entry point with a TODO. Wiring the row into the detail view belongs with the surface work, not here, and that file is being rewritten on #122949. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Measured against the mock, every value from a token. The evidence rows sat on a grey surface; in the spec they sit on the card and are separated by their border alone, so they move to the primary background. The rationale was muted, which put it in the same register as the evidence results below it; the spec reads it as body copy, so it takes the primary content colour and only the results stay muted. Spacing was uniformly tighter than the spec: card padding lg -> xl, card gap md -> lg, evidence rows sm/md -> md/lg with a sm gap between them, and the gap between cards md -> xl, which now matches the card's own padding. Type sizes and weights were already right and are unchanged. Worth noting the scale tops out at 500 -- there is no bolder weight token -- so the statement is as heavy as the system goes. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Taken from the prototype recording, which shows the row moving through states the card had been collapsing into one. A hypothesis in flight is a single effectiveStatus, but the recording names four moments inside it: Formed, Preparing checks, Checking, and Evidence checked. The distinction only exists in the verification steps -- whether any are planned, and whether they have produced anything -- so that is where it is read from. Only Checking is coloured and keeps its dot moving; the others are staging posts, not outcomes, and the old code lit all of them accent. The heading above the steps moves with them, from "Evidence to check" to "Evidence checked", and a step with nothing yet reads "Awaiting evidence" rather than naming its queue position. A step that has produced something now opens, revealing the objective and method behind it -- fields the projection has always carried and the card never showed. A step with no result stays a bare row, because a chevron there would promise a finding that does not exist yet. Accepted and rejected keep their own labels rather than folding into supported and refuted, so a decision is never misread as a verdict. Confidence is lowercase, matching the recording. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Verified in a browser this time, which caught four things reading the code did not. The evidence row's summary was passed as the Disclosure title's children, so it landed inside a Button: centred, held to one line, and spilling past the row's right edge. Moving it to `leadingItems` puts it outside the button, where it lays out as ordinary content and, taking the row's spare width, pushes the chevron to the edge the prototype has it on. That also removed the doubled padding, since the Disclosure supplies its own. The toggle had no accessible name once the summary moved out of it -- a screen reader heard "button, collapsed" and nothing else. It now says which check it opens. The statement and its rationale were running together as one block, and the story demos were clipping at Storybook.Demo's 512px maximum, so most of each row was only reachable by scrolling inside the frame. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
The row talked to the real endpoints already, but nothing mounted it, so it never appeared in the product. `knip --production` had been reporting exactly that and the config carried a TODO entry point to keep it quiet; both are gone now because the detail view reaches it. `orchestration` on the investigation is what gates it. The field is already served inline on the list and detail responses and is null for manual and template investigations, whose orchestration endpoint 404s. The proof of concept gated on `investigation.mode === 'agentic'` instead, but no `mode` field exists -- the summary is the only marker an investigation carries. The detail query now also polls while a run is live. It previously stopped as soon as no block was executing, which would freeze the gate: a run that started after load would never show its row, and one that finished would keep claiming to be running.
The button went grey with nothing to explain it, and the two reasons it does so are not guessable from the page. A missing open period is named outright: the page already lists open periods, so saying there is none gives nothing away. The other reason cannot be as specific. `unavailable` from the candidates endpoint covers an issue that cannot be investigated at all, an existing investigation in a project the viewer cannot see, and a viewer who may not create one -- collapsed on purpose, since the resolver it comes from notes that a caller "should not reveal whether an inaccessible or invalid issue exists". Distinguishing those in a tooltip would leak precisely that, so the wording covers them together and points at the cause someone can actually act on. Neither reason is ever "still loading": a pending query renders a placeholder in place of the button, and a failed one renders an alert. Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv
…heses Frames 4705-4707 of the Seer Investigation design put a status block above the hypothesis row: one line saying what the agent is doing, with a chip and an elapsed counter, and the row beneath it inside a shared panel. The block is one component for the whole lifecycle, because the shape never changes -- icon, sentence, chip, time -- only the words and the colour do. It sits in a fixed spot for the life of a run, so a reader who has learned where to look for "what is happening" never has to relearn it. `status` picks the variant and `phase` picks the words: every in-flight phase shares one `processing` status, so the phase is the only thing separating "gathering context" from "finalizing". The design's chip turned out to be the existing `Tag`: `variant="info"` resolves to `content.accent` on `background.transparent.accent.muted`, which is exactly what the Figma variables name. No new styled component needed. Two states are not from the frames and are marked as such. `cancelled` is here because the projection can report it and falling through to `failed` would paint a decision someone deliberately made bright red. `elapsed` is an optional prop the wired block leaves empty -- the projection carries no run-level start time, only per-block `startedAt` -- so it renders in the story and nowhere else rather than counting from an invented origin. The cards move with it. A hypothesis being verified now keeps a solid border instead of a dashed one, because dashing it announces a verdict the agent has not reached; dashed is reserved for a card that was checked and is not the answer. "Checking" becomes "Verifying..." and is drawn as a spinning ring rather than a pulsing dot, and `refuted` reads amber rather than muted -- a hypothesis the agent tested and closed is not an error, but it is a result worth registering as you scan the row. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Neither entry point produced an investigation with hypotheses. The server decides from the request body -- a `source` with no `templateKey` builds an agentic run, anything else builds a plain notebook -- and both buttons sent a shape that landed elsewhere. "Investigate" on a metric issue now sends the metric snapshot alone, dropping the template key. The candidates endpoint already matches agentic and template lineage keys alike, so a breach that was investigated before still resolves to "View" rather than offering a duplicate. "New investigation" sends a manual source. That run opens `awaiting_input` and stays there until someone supplies a prompt, which nothing in the UI does yet, so the investigation behaves as before -- an empty notebook -- with an idle run attached. That idle run is why polling changed. The predicate asked whether a run had reached a terminal status, and `awaiting_input` has not; left alone, every newly created investigation would have polled the detail and orchestration endpoints every two seconds forever. It now asks whether the agent is advancing, which `awaiting_input` is not: it is blocked on a person, and supplying input writes the new projection into the cache and starts it again. Note that this does not gate the behaviour behind anything beyond the existing organizations:investigations flag -- every investigation created in a flagged org becomes agentic. Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv
…nts" This reverts commit 02a6dba.
The agent writes its findings in terms of what it read, so they are mostly symbols: module/file.py::function_name, dotted paths, issue short IDs. None of them carry a break opportunity, so a token wider than the column pushed its own text out through the card edge instead of wrapping inside it. Set the scraps wordBreak prop on every agent-written string in the card, not just the evidence detail that surfaced it. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Drop the block count from the subheader, the status badge and the Seer mark from the header actions, and the debug-only add text/query cell composer. The main content area now fills the page and left-aligns with the rest of the product rather than sitting in a centred 884px column. Three separate caps were producing that column: the header grid, the canvas wrapper, and the two stacks around the hypothesis row and the notebook. Removing the composer left useAddInvestigationBlockMutation with no callers, so it goes too. The specs that reached the behaviour through it now exercise it directly: the never-run cell test uses the fixture's own unrun query block, and the fixture-API mutation test drives a rename instead. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
|
bugbot review |
Reverts the fluid-width half of 45de482. Running the notebook to the full page width stretched the prose past a comfortable measure, and body text is most of what this view renders. The header grid, the canvas, the hypothesis row and the notebook column go back to the centred 884px column. Everything else from that commit stands: the block count, the status badge, the Seer mark and the debug-only add-cell composer stay gone. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Two defects the review bot found in the hypothesis row. A settled run stops polling, but accept, reject and retry stay on the menu -- and acting on a finished run is the main reason to open it. Sentry only queues a command: the response carries the projection it already had with nothing but workflowVersion moved on, and Seer rewrites the real one later. The card therefore kept its old disposition until someone reloaded. An accepted command now reopens polling for a bounded window, long enough for Seer to apply it and short enough not to poll a stopped run forever. Separately, verificationSteps is declared required=False with no default on the contract, so DRF omits the key rather than sending an empty list. The frontend type claimed it was always there, and two call sites read .length and .filter straight off it -- a hypothesis the agent has only just formed would crash the row it appears in. The type now says what the wire says, which is what surfaced the third unguarded caller. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6365e30. Configure here.
| <Text variant="muted">{t('%s blocks', investigation.blockCount)}</Text> | ||
| <MetaDivider /> |
There was a problem hiding this comment.
removed bc it wasn't in the mocks, but could add it back is we think it's useful
| <Badge variant={getStatusVariant(investigation.status)}> | ||
| {formatStatus(investigation.status)} | ||
| </Badge> |
There was a problem hiding this comment.
there's another badge elsewhere
| * being present is the only thing that says one is: it is null for | ||
| * manual and template investigations, whose orchestration endpoint |
There was a problem hiding this comment.
This isn't true about manual/template as we're going to change it to be agentic.
| {investigation.status === 'active' ? ( | ||
| <AddCellComposer | ||
| isAdding={addBlockMutation.isPending} | ||
| onAdd={handleAddBlock} | ||
| /> | ||
| ) : null} |
There was a problem hiding this comment.
Intentional, no longer needed (it was for v0 and debugging)
| another readable card — the viewport is never consulted. Drag the demo's edge to | ||
| watch it reflow. | ||
| </p> | ||
| <Storybook.Demo resizable direction="column" align="stretch"> |
| * apply, and supplies them here. No menu renders when this is empty. | ||
| */ | ||
| actions?: MenuItemProps[]; | ||
| className?: string; |
| </Text> | ||
| <EvidenceList as="ul" gap="sm" padding="0"> | ||
| {steps.map(step => ( | ||
| <VerificationStepRow key={step.id} step={step} /> |
| function humanize(status: string): string { | ||
| return status.replaceAll('_', ' ').replace(/^./, character => character.toUpperCase()); | ||
| } |
| return t( | ||
| '%s • %s', | ||
| tn('%s possible cause', '%s possible causes', causeCount), | ||
| tn('%s check completed', '%s checks completed', checkCount) | ||
| ); |
There was a problem hiding this comment.
might not need the extra translation for the dot
Per review. `humanize` was local to the hypothesis status module, but there is nothing investigation-specific about it: it is the fallback any open-set wire value needs when the frontend meets a name it does not have a translated label for. It sits alongside `capitalize` rather than reusing it, because the rest of the value is deliberately left alone -- an acronym the API sent in caps reads better kept that way, and `capitalize` lowercases everything after the first letter. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Per review: the toggle on an evidence row was a ~24px chevron on a row several hundred pixels wide. `Disclosure.Title` renders `leadingItems` outside its button, so putting the step's summary there left the chevron alone inside it. The summary is now the title's children, which is what the component expects: one full-width stretched button spanning the row, named by the text it contains rather than by a separate aria-label. The chevron moves to the left of the summary, where every other Disclosure in the app puts it. The reason the summary was in the leading slot still holds -- `Button` is sized as a single-line control, so a two-line block of title-plus-result needs the fixed height, `nowrap` and centred contents overridden to sit inside one. That is what the styled title does, and `&&` keeps those rules from depending on emotion's insertion order against the button's own class. While here, drop the card's `className` prop: nothing passes one. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
Per review. The run tally was assembled with `t('%s • %s', ...)`, which puts a
format string whose only content is a bullet into the catalog. Each half is
already translated on its own; the bullet between them is punctuation, so the
two are joined directly.
Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
CI failure, from a rule that landed on master after this branch: scraps'
`require-render-prop-spread` rejects destructuring the parameter a scraps render
function receives.
The status line took `{({className}) => ...}` and put it on the `Flex`. Today
that is the whole object -- `Text` calls its render function with exactly
`{className}` -- but naming the one prop is what the rule is about: the callee
decides what it hands down, and a destructure silently drops anything added to
it later. Spreading forwards whatever arrives.
Nothing about the rendered output changes, which is why the status specs are
untouched.
Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL
…4334) This changes the metric issue and the new investigation entry points so that they create the new agentic style investigation (e.g. the one w/ the hypothesis). ## Slop Neither entry point produced an investigation with hypotheses. The server decides from the request body — a `source` with no `templateKey` builds an agentic run, anything else builds a plain notebook — and both buttons sent a shape that landed elsewhere. **"Investigate" on a metric issue** now sends the metric snapshot alone, dropping the template key. The candidates endpoint already matches agentic and template lineage keys alike, so a breach that was investigated before still resolves to "View" rather than offering a duplicate. **"New investigation"** sends a manual source. That run opens `awaiting_input` and stays there until someone supplies a prompt, which nothing in the UI does yet, so the investigation behaves as before — an empty notebook — with an idle run attached. That idle run is why polling changed. The predicate asked whether a run had reached a terminal status, and `awaiting_input` has not; left alone, every newly created investigation would have polled the detail and orchestration endpoints every two seconds forever. It now asks whether the agent is advancing, which `awaiting_input` is not: it is blocked on a person, and supplying input writes the new projection into the cache and starts it again. Note that this does not gate the behaviour behind anything beyond the existing `organizations:investigations` flag — every investigation created in a flagged org becomes agentic. ## Base branch This targets `billyvong/investigations-hypothesis-ui` (#124086) rather than `master`: it edits `static/app/views/investigations/hypotheses/investigationHypotheses.tsx`, which only exists on that branch. It was originally committed there and reverted so that PR could stay focused; this is that commit, cherry-picked. ## Visual verification Blocked, and not skipped silently: this change lives in the request body sent when an investigation is **created**, so exercising it needs a local backend to create against. `devservices` is not running (Docker daemon is down), and the only other reachable API is production, which is not a valid QA target. Screenshots from the Storybook fixture API would not be evidence of this change, since the fixtures do not exercise the creation path. Happy to capture the real states if `devservices` gets brought up. ## Checks - `pnpm run typecheck` clean - `pnpm run lint:js` clean - 621 tests pass across `static/app/views/investigations/` and `static/app/views/detectors/` https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL

Renders the hypotheses an agentic investigation is weighing as a row of cards: the statement, where it landed, the confidence behind that, and the checks the agent ran to get there.
Frontend only. No
src/changes.Where the data comes from
Nothing new is needed on the wire. Seer pushes the whole live state of a run as one projection blob on every orchestration event, Sentry stores it on
InvestigationOrchestrationRun.projection, andGET /organizations/{org}/investigations/{id}/orchestration/serves the latest one.HypothesisSerializerinsrc/sentry/investigations/contracts.pyalready carries everything these cards show:statementrationaleeffectiveStatus— already folds the agent verdict and any user disposition into the run statusconfidence, falling back toagentVerdict.confidenceverificationSteps[].title+.resultSo the agent decides what these cards say purely by what it writes into
projection.hypotheses. There is no separate signal telling the frontend to render a hypothesis, and no new block kind — hypotheses are derived agent state that churns during a run, which is why they live in one atomically-overwritten blob rather than asInvestigationBlockrows.Decisions travel back the other way as versioned commands fenced on
workflowVersion, so one made against a stale view is rejected rather than applied to a run that has moved on.Layout
The row reflows on its container rather than the viewport:
auto-fitoverminmax(260px, 1fr)tracks drops columns whenever the available space stops fitting another readable card. That lets the same component sit in a full-width detail view and in a narrow drawer without a breakpoint prop or acontainerTypeon the parent — and it is why this does not draw the connecting edges the flow-graph mock has.Relationship to #122949
That branch has an
@xyflow/reactHypothesisGraphthat lays each hypothesis out as a lane of connected nodes, with details behind a modal. This is the alternative for surfaces that need to fit smaller widths, with the evidence inline on the card instead.Types and API signatures here match the ones on that branch exactly —
InvestigationOrchestration,InvestigationHypothesis,investigationOrchestrationQueryOptions(orgSlug, id),useInvestigationOrchestrationCommandMutation(orgSlug, id)— so the two collapse into one rather than conflicting, whichever lands first.Storybook
InvestigationFixtureApi, the in-memory fake the other investigations stories use, now serves the two orchestration routes and applies commands in memory, so accept / reject / retry stay interactive on the stories page rather than being static props.Stories cover the row, every status, and the connected component running against that fixture API. The row sits in
Storybook.Demo resizable, so reflow is demonstrated by dragging the demo's edge rather than by hardcoded widths.Card states
The border has two states, not one per status:
effectiveStatussupported,acceptedtokens.border.accent.vibrant(#7553FF)A solid accent edge marks the explanation that stands: supported by the evidence, or accepted by a person. Everything else is dashed, because investigating, refuted, inconclusive, cancelled and failed all read the same way to someone scanning the row — not the answer. The status line already says which of them it is, so the border does not repeat it. (#122949 instead maps
refutedandrejectedtodanger.)Dashed rather than dotted: a dotted hairline is barely visible at this border color.
report.primaryHypothesisIdis a shadow rather than a border, so the two signals stay separable — the border says whether a hypothesis is the answer, the lift says which one the report is built around.The status line keeps semantic content tokens, which resolve to the mock's colors: supported
#008900, inconclusive#A45200, refuted/pending muted#6A6772.getBorderin the layout primitives only ever emits1px solid, so the dashed case is a small CSS escape on the card; the colors still come from border tokens. Both states are driven by adata-borderattribute rather than a styled prop, which keeps them assertable — this repo stubsgetComputedStyleto inline styles only, so emotion CSS cannot be asserted in Jest.Verification
pnpm run typecheckcleanpnpm run lint:js static/app/views/investigationscleanpnpm test-ci static/app/views/investigations— 106 passing, including the story's own contents rendering against the mocked orchestration APIhttps://claude.ai/code/session_014zh69vex76pjNTnarqVcXL