Files
zeekayandHanzo Dev b497511c04 no hogs: stamphog and review_hog are stamp and review
Both are Django apps, and both pin `label` in apps.py — "stamp" and "review".
django_migrations keys a row on (app_label, name), and app_label comes from that
pin and not from the import path, so moving the directory does not orphan a
single migration. All six stay where they are, untouched.

  products/stamphog -> products/stamp     name = products.stamp.backend
  products/review_hog -> products/review  name = products.review.backend
  StamphogScene/ -> StampScene/, and stamphogSceneLogic.ts with it

189 files follow. An earlier attempt at this move failed twice for reasons worth
recording: `git mv A B` where B exists puts A INSIDE B, and a stray
products/stamp/node_modules from an abandoned run was enough to make B exist —
the app ended up nested two deep. And `git ls-files` does not list a file moved
with plain mv and not yet staged, so a pass over "every tracked file" silently
skips it; that is how StampScene.tsx kept importing ./stampSceneLogic after the
file beside it had been renamed back.

bin/check-imports-are-committed: all first-party imports across 15,843 files
resolve.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:18:18 -07:00

1046 lines
43 KiB
TypeScript

import { MakeLogicType, actions, afterMount, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { actionToUrl, router, urlToAction } from 'kea-router'
import { toast } from '@hanzo/elements'
import api from 'lib/api'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
import { OriginProduct } from 'products/insights_ai/frontend/types/taskTypes'
import {
reviewBlindSpotsList,
reviewBlindSpotsPartialUpdate,
reviewPerspectivesList,
reviewPerspectivesPartialUpdate,
reviewReviewsList,
reviewReviewsPerspectiveStatsRetrieve,
reviewReviewsRetrieve,
reviewReviewsTriggerCreate,
reviewSettingsPartialUpdate,
reviewSettingsRetrieve,
reviewValidatorsList,
reviewValidatorsPartialUpdate,
} from 'products/review/frontend/generated/api'
import type {
PatchedReviewUserSettingsApi,
ReviewBlindSpotsConfigApi,
ReviewDetailApi,
ReviewFindingApi,
ReviewIssuePriorityEnumApi,
ReviewPerspectiveConfigApi,
ReviewPerspectiveStatsApi,
ReviewRecentReviewApi,
ReviewRecentReviewsPageApi,
ReviewUserSettingsApi,
ReviewValidatorConfigApi,
} from 'products/review/frontend/generated/api.schemas'
import { ReviewReviewsListScope } from 'products/review/frontend/generated/api.schemas'
export type ReviewSkillKind = 'perspective' | 'blind_spots' | 'validator'
export type ReviewDrawerTab = 'published' | 'below_threshold' | 'dismissed' | 'chunks' | 'review'
export const REVIEW_PRIORITY_RANK: Record<ReviewIssuePriorityEnumApi, number> = {
consider: 0,
should_fix: 1,
must_fix: 2,
}
// While a review is running, the list refreshes on this cadence so the stage/progress row is live.
const IN_PROGRESS_POLL_INTERVAL_MS = 10_000
// How long after triggering a review the list keeps polling for its report row to appear — the row
// is created seconds after the 202 by the workflow's fetch step, but a run that dies before creating
// it must not keep the list polling forever.
const TRIGGERED_REVIEW_WATCH_TIMEOUT_MS = 2 * 60 * 1000
/** The review list's initial depth, the step each "Show more" adds, and what "Show fewer" collapses to. */
export const REVIEWS_PAGE_SIZE = 5
/** Mirrors MAX_REVIEWS_LIMIT in reviews.py — the API 400s above this, so growth must stop here. */
export const MAX_REVIEWS_LIMIT = 100
/** The detail's valid findings split by the user's urgency threshold: on the PR vs. kept back. */
export interface ReviewFindingsSplit {
published: ReviewFindingApi[]
belowThreshold: ReviewFindingApi[]
}
/** How many surviving findings each review skill contributed, largest contributor first. */
export interface PerspectiveScore {
skillName: string
count: number
}
/** The skill a "View skill" click opens in the read-only drawer. */
export interface ViewedSkill {
title: string
body: string
/** The `review-script-*` skill name, for the drawer's link to the skill's editor page. */
skillName: string
}
// Thin scout-style kickoff pointers (mirrors SCOUT_AUTHOR_PROMPT): the actual authoring guide —
// pipeline context, naming contract, per-kind body shape, activation steps — lives in the
// `review-script-authoring` team skill (canonical source: products/review/skills/), synced per
// team like the perspectives. Keep knowledge there, not here.
const SKILL_AUTHOR_TASKS: Record<ReviewSkillKind, { title: string; prompt: string }> = {
perspective: {
title: 'Create a Review perspective',
prompt: `I'd like to create a custom Review review perspective for this Insights project.
Use the review-script-authoring skill from the Insights MCP to guide creating it — follow its review-perspective path.
Ground yourself per that skill first, then ask me what my perspective should focus on and offer a few concrete directions the current set doesn't already cover. Once I pick, author the skill end to end and tell me how to switch it on.
If the review-script-authoring skill is unavailable, fall back to the Insights MCP skill tools directly: list the team's review-script-perspective-* skills and read a canonical one to learn the shape before authoring.`,
},
blind_spots: {
title: 'Create a Review blind-spot check',
prompt: `I'd like to create a custom Review blind-spot check for this Insights project.
Use the review-script-authoring skill from the Insights MCP to guide creating it — follow its blind-spot-check path.
Ground yourself per that skill first, then ask me what my sweep should emphasize and offer a few concrete directions. Once I pick, author the skill end to end and tell me how to switch it on.
If the review-script-authoring skill is unavailable, fall back to the Insights MCP skill tools directly: read the canonical review-script-blind-spots-general skill to learn the shape before authoring.`,
},
validator: {
title: 'Create Review validation criteria',
prompt: `I'd like to create custom Review validation criteria for this Insights project.
Use the review-script-authoring skill from the Insights MCP to guide creating it — follow its validation-criteria path.
Ground yourself per that skill first, then ask me how my bar should differ — stricter, more lenient, or weighted toward specific concerns — and offer a few concrete directions. Once I pick, author the skill end to end and tell me how to switch it on.
If the review-script-authoring skill is unavailable, fall back to the Insights MCP skill tools directly: read the canonical review-script-validation-criteria skill to learn the shape before authoring.`,
},
}
function currentProjectId(): string {
return String(teamLogic.values.currentTeamId)
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface reviewSettingsLogicValues {
awaitingTriggeredReview: boolean
blindSpots: ReviewBlindSpotsConfigApi[] | null
blindSpotsLoading: boolean
creatingSkillKind: ReviewSkillKind | null
expandedReviewIds: string[]
hasUserChosenReviewsScope: boolean
initialLoadFailed: boolean
moreReviewsAvailable: boolean
openedReview: ReviewRecentReviewApi | null
openedReviewId: string | null
perspectiveScoreboard: PerspectiveScore[] | null
perspectiveStats: ReviewPerspectiveStatsApi | null
perspectiveStatsLoading: boolean
perspectives: ReviewPerspectiveConfigApi[] | null
perspectivesLoading: boolean
pipelineDetailOpen: boolean
recentReviews: ReviewRecentReviewApi[] | null
recentReviewsPage: ReviewRecentReviewsPageApi | null
recentReviewsPageLoading: boolean
reviewDetail: ReviewDetailApi | null
reviewDetailLoading: boolean
reviewDrawerOpen: boolean
reviewDrawerTab: ReviewDrawerTab
reviewFindingsSplit: ReviewFindingsSplit | null
reviewsExpanding: boolean
reviewsLimit: number
reviewsScope: ReviewReviewsListScope
savingSkillNames: string[]
settings: ReviewUserSettingsApi | null
settingsLoading: boolean
skillDrawerOpen: boolean
triggerPrUrl: string
triggeringReview: boolean
validators: ReviewValidatorConfigApi[] | null
validatorsLoading: boolean
viewedSkill: ViewedSkill | null
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface reviewSettingsLogicActions {
applyDefaultReviewsScope: (scope: ReviewReviewsListScope) => {
scope: ReviewReviewsListScope
}
blockSingleActiveDeactivation: (kindLabel: string) => {
kindLabel: string
}
closePipelineDetail: () => {
value: true
}
closeReviewDrawer: () => {
value: true
}
closeSkillDrawer: () => {
value: true
}
loadAll: () => {
value: true
}
loadBlindSpots: () => any
loadBlindSpotsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadBlindSpotsSuccess: (
blindSpots: ReviewBlindSpotsConfigApi[],
payload?: any
) => {
blindSpots: ReviewBlindSpotsConfigApi[]
payload?: any
}
loadPerspectiveStats: (_?: any) => any
loadPerspectiveStatsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadPerspectiveStatsSuccess: (
perspectiveStats: ReviewPerspectiveStatsApi,
payload?: any
) => {
perspectiveStats: ReviewPerspectiveStatsApi
payload?: any
}
loadPerspectives: () => any
loadPerspectivesFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadPerspectivesSuccess: (
perspectives: ReviewPerspectiveConfigApi[],
payload?: any
) => {
perspectives: ReviewPerspectiveConfigApi[]
payload?: any
}
loadRecentReviews: (_?: any) => any
loadRecentReviewsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadRecentReviewsSuccess: (
recentReviewsPage: ReviewRecentReviewsPageApi,
payload?: any
) => {
recentReviewsPage: ReviewRecentReviewsPageApi
payload?: any
}
loadReviewDetail: (reviewId: string) => string
loadReviewDetailFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadReviewDetailSuccess: (
reviewDetail: ReviewDetailApi,
payload?: string
) => {
reviewDetail: ReviewDetailApi
payload?: string
}
loadSettings: () => any
loadSettingsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadSettingsSuccess: (
settings: ReviewUserSettingsApi,
payload?: any
) => {
settings: ReviewUserSettingsApi
payload?: any
}
loadValidators: () => any
loadValidatorsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadValidatorsSuccess: (
validators: ReviewValidatorConfigApi[],
payload?: any
) => {
validators: ReviewValidatorConfigApi[]
payload?: any
}
openPipelineDetail: () => {
value: true
}
openReviewDetail: (review: ReviewRecentReviewApi) => {
review: ReviewRecentReviewApi
}
openReviewDetailById: (reviewId: string) => {
reviewId: string
}
patchPerspectiveLocally: (
skillName: string,
enabled: boolean
) => {
enabled: boolean
skillName: string
}
selectBlindSpots: (skillName: string) => {
skillName: string
}
selectValidator: (skillName: string) => {
skillName: string
}
setReviewDrawerTab: (tab: ReviewDrawerTab) => {
tab: ReviewDrawerTab
}
setReviewsScope: (scope: ReviewReviewsListScope) => {
scope: ReviewReviewsListScope
}
setSkillSaving: (
skillName: string,
saving: boolean
) => {
saving: boolean
skillName: string
}
setTriggerPrUrl: (prUrl: string) => {
prUrl: string
}
showFewerReviews: () => {
value: true
}
showMoreReviews: () => {
value: true
}
startSkillAuthorTask: (kind: ReviewSkillKind) => {
kind: ReviewSkillKind
}
startSkillAuthorTaskFinished: () => {
value: true
}
startTriggeredReviewWatch: () => {
value: true
}
stopTriggeredReviewWatch: () => {
value: true
}
submitTriggerReview: () => {
value: true
}
submitTriggerReviewFinished: () => {
value: true
}
submitTriggerReviewStarted: () => {
value: true
}
togglePerspective: (
skillName: string,
enabled: boolean
) => {
enabled: boolean
skillName: string
}
toggleReviewRowExpanded: (reviewId: string) => {
reviewId: string
}
updateSettings: (patch: PatchedReviewUserSettingsApi) => PatchedReviewUserSettingsApi
updateSettingsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
updateSettingsSuccess: (
settings: ReviewUserSettingsApi,
payload?: PatchedReviewUserSettingsApi
) => {
settings: ReviewUserSettingsApi
payload?: PatchedReviewUserSettingsApi
}
viewSkill: (skill: ViewedSkill) => {
skill: ViewedSkill
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface reviewSettingsLogicMeta {
__keaTypeGenInternalSelectorTypes: {
recentReviews: (recentReviewsPage: ReviewRecentReviewsPageApi | null) => ReviewRecentReviewApi[] | null
moreReviewsAvailable: (recentReviewsPage: ReviewRecentReviewsPageApi | null, reviewsLimit: number) => boolean
reviewFindingsSplit: (
reviewDetail: ReviewDetailApi | null,
settings: ReviewUserSettingsApi | null
) => ReviewFindingsSplit | null
perspectiveScoreboard: (reviewDetail: ReviewDetailApi | null) => PerspectiveScore[] | null
}
}
export type reviewSettingsLogicType = MakeLogicType<
reviewSettingsLogicValues,
reviewSettingsLogicActions,
Record<string, any>,
reviewSettingsLogicMeta
>
/**
* State for the "Code review" scene: the user's Review settings (triggers + urgency
* threshold) and the three skill lists (perspectives / blind-spot check / validation criteria)
* with their per-user enablement. Cardinality rules mirror the backend: perspectives keep a
* min-1 floor, blind spots and validators are exactly-one-active (deactivation is blocked,
* you switch by selecting another). "Create your own …" kicks off an authoring agent task,
* mirroring the Inbox "Make a scout" flow.
*/
export const reviewSettingsLogic = kea<reviewSettingsLogicType>([
path(['products', 'review', 'frontend', 'reviewSettingsLogic']),
actions({
// Fires the four initial loads — also the retry entry point after a failed load.
loadAll: true,
togglePerspective: (skillName: string, enabled: boolean) => ({ skillName, enabled }),
patchPerspectiveLocally: (skillName: string, enabled: boolean) => ({ skillName, enabled }),
selectBlindSpots: (skillName: string) => ({ skillName }),
selectValidator: (skillName: string) => ({ skillName }),
// The active card's switch can't be turned off — exactly one stays active per kind.
blockSingleActiveDeactivation: (kindLabel: string) => ({ kindLabel }),
setSkillSaving: (skillName: string, saving: boolean) => ({ skillName, saving }),
viewSkill: (skill: ViewedSkill) => ({ skill }),
closeSkillDrawer: true,
openReviewDetail: (review: ReviewRecentReviewApi) => ({ review }),
// Opens the drawer for a review with no list row in hand — the `?review=` deep-link path
// (PR status comments bake these links into GitHub). The drawer renders from the loaded
// detail alone.
openReviewDetailById: (reviewId: string) => ({ reviewId }),
closeReviewDrawer: true,
openPipelineDetail: true,
closePipelineDetail: true,
setReviewDrawerTab: (tab: ReviewDrawerTab) => ({ tab }),
toggleReviewRowExpanded: (reviewId: string) => ({ reviewId }),
setReviewsScope: (scope: ReviewReviewsListScope) => ({ scope }),
showMoreReviews: true,
showFewerReviews: true,
// Auto-select a default scope (Entire project when the user has no reviews of their own)
// without marking it as an explicit user choice, so a later real choice still wins.
applyDefaultReviewsScope: (scope: ReviewReviewsListScope) => ({ scope }),
startSkillAuthorTask: (kind: ReviewSkillKind) => ({ kind }),
startSkillAuthorTaskFinished: true,
setTriggerPrUrl: (prUrl: string) => ({ prUrl }),
// Starts a review of the pasted PR URL. The listener self-guards on `triggeringReview`, so a
// repeat dispatch mid-flight (Enter spam, double click) is a no-op regardless of the source.
submitTriggerReview: true,
submitTriggerReviewStarted: true,
submitTriggerReviewFinished: true,
// Keeps the recent-reviews poll alive until a just-triggered review's report row appears —
// without it the poll only arms when some other review is already visibly running.
startTriggeredReviewWatch: true,
stopTriggeredReviewWatch: true,
}),
loaders(({ values }) => ({
settings: [
null as ReviewUserSettingsApi | null,
{
loadSettings: async () => await reviewSettingsRetrieve(currentProjectId()),
updateSettings: async (patch: PatchedReviewUserSettingsApi, breakpoint) => {
const response = await reviewSettingsPartialUpdate(currentProjectId(), patch)
// A newer update is already in flight — drop this now-stale response.
breakpoint()
return response
},
},
],
perspectives: [
null as ReviewPerspectiveConfigApi[] | null,
{
loadPerspectives: async () => await reviewPerspectivesList(currentProjectId()),
},
],
blindSpots: [
null as ReviewBlindSpotsConfigApi[] | null,
{
loadBlindSpots: async () => await reviewBlindSpotsList(currentProjectId()),
},
],
validators: [
null as ReviewValidatorConfigApi[] | null,
{
loadValidators: async () => await reviewValidatorsList(currentProjectId()),
},
],
recentReviewsPage: [
null as ReviewRecentReviewsPageApi | null,
{
// The default param keeps the action zero-arg in the generated logic type.
loadRecentReviews: async (_ = null, breakpoint) => {
const { reviewsScope: scope, reviewsLimit: limit } = values
const response = await reviewReviewsList(currentProjectId(), { scope, limit })
// A scope or limit change mid-flight dispatched a newer load — drop this stale response.
breakpoint()
return response
},
},
],
reviewDetail: [
null as ReviewDetailApi | null,
{
loadReviewDetail: async (reviewId: string, breakpoint) => {
const response = await reviewReviewsRetrieve(currentProjectId(), reviewId)
// A newer open (row click, ?review= navigation) dispatched a fresh load
// mid-flight — drop this stale response so out-of-order responses can't
// attach the wrong review's findings to the drawer.
breakpoint()
return response
},
},
],
perspectiveStats: [
null as ReviewPerspectiveStatsApi | null,
{
// The default param keeps the action zero-arg in the generated logic type.
loadPerspectiveStats: async (_ = null, breakpoint) => {
const response = await reviewReviewsPerspectiveStatsRetrieve(currentProjectId(), {
scope: values.reviewsScope,
})
// A scope change mid-flight dispatched a newer load — drop this stale response.
breakpoint()
return response
},
},
],
})),
reducers({
settings: {
// Optimistic: the switches/slider reflect the change immediately; a failure listener
// reloads to reconcile.
updateSettings: (state: ReviewUserSettingsApi | null, patch: PatchedReviewUserSettingsApi) =>
state ? { ...state, ...patch } : state,
},
perspectives: {
patchPerspectiveLocally: (state: ReviewPerspectiveConfigApi[] | null, { skillName, enabled }) =>
state?.map((p) => (p.skill_name === skillName ? { ...p, enabled } : p)) ?? state,
},
blindSpots: {
selectBlindSpots: (state: ReviewBlindSpotsConfigApi[] | null, { skillName }) =>
state?.map((s) => ({ ...s, active: s.skill_name === skillName })) ?? state,
},
validators: {
selectValidator: (state: ReviewValidatorConfigApi[] | null, { skillName }) =>
state?.map((s) => ({ ...s, active: s.skill_name === skillName })) ?? state,
},
savingSkillNames: [
[] as string[],
{
setSkillSaving: (state, { skillName, saving }) =>
saving ? [...state, skillName] : state.filter((name) => name !== skillName),
},
],
viewedSkill: [
// Kept through close so the drawer doesn't blank mid-animation; `skillDrawerOpen` gates visibility.
null as ViewedSkill | null,
{
viewSkill: (_, { skill }) => skill,
},
],
skillDrawerOpen: [
false,
{
viewSkill: () => true,
closeSkillDrawer: () => false,
},
],
// The clicked list row: the drawer header renders from it instantly while the detail loads.
// Null on a deep-linked open (no row in hand) — the drawer then renders from the detail alone.
openedReview: [
null as ReviewRecentReviewApi | null,
{
openReviewDetail: (_, { review }) => review,
openReviewDetailById: () => null,
},
],
// The review the drawer is showing (or loading), by id — set synchronously by both open
// paths, so the URL sync can tell "this location event is my own write-back" apart from a
// genuinely new deep link before the detail has loaded.
openedReviewId: [
null as string | null,
{
openReviewDetail: (_, { review }) => review.id,
openReviewDetailById: (_, { reviewId }) => reviewId,
closeReviewDrawer: () => null,
},
],
reviewDetail: {
// Clear the previous review's detail so opening another row never flashes stale findings.
openReviewDetail: () => null,
openReviewDetailById: () => null,
},
reviewDrawerOpen: [
false,
{
openReviewDetail: () => true,
openReviewDetailById: () => true,
closeReviewDrawer: () => false,
},
],
pipelineDetailOpen: [
false,
{
openPipelineDetail: () => true,
closePipelineDetail: () => false,
},
],
reviewDrawerTab: [
'published' as ReviewDrawerTab,
{
setReviewDrawerTab: (_, { tab }) => tab,
openReviewDetail: () => 'published' as ReviewDrawerTab,
openReviewDetailById: () => 'published' as ReviewDrawerTab,
},
],
expandedReviewIds: [
[] as string[],
{
toggleReviewRowExpanded: (state, { reviewId }) =>
state.includes(reviewId) ? state.filter((id) => id !== reviewId) : [...state, reviewId],
},
],
perspectiveStats: {
// A different scope is different data — drop the old numbers so the stat cards show
// skeletons instead of the wrong scope's stats while the reload is in flight.
setReviewsScope: () => null,
applyDefaultReviewsScope: () => null,
},
recentReviewsPage: {
// A different scope is a different list — drop it together with perspectiveStats so
// the section shows skeletons instead of the other scope's rows (and never strands
// them if the reload fails). Poll refreshes still keep prior rows.
setReviewsScope: () => null,
applyDefaultReviewsScope: () => null,
// "Show fewer" collapses instantly from data already loaded; the listener's refetch
// reconciles silently and breakpoint-drops any wider in-flight response (e.g. a poll).
showFewerReviews: (state: ReviewRecentReviewsPageApi | null) =>
state
? {
...state,
results: state.results.slice(0, REVIEWS_PAGE_SIZE),
has_more: state.has_more || state.results.length > REVIEWS_PAGE_SIZE,
}
: state,
},
// How many rows the review list asks for — grows by a page per "Show more".
reviewsLimit: [
REVIEWS_PAGE_SIZE as number,
{
showMoreReviews: (state) => Math.min(state + REVIEWS_PAGE_SIZE, MAX_REVIEWS_LIMIT),
showFewerReviews: () => REVIEWS_PAGE_SIZE,
// A different scope is a different list — start it compact again.
setReviewsScope: () => REVIEWS_PAGE_SIZE,
applyDefaultReviewsScope: () => REVIEWS_PAGE_SIZE,
},
],
// Drives the "Show more" button's loading state — the loader's own `loading` would also
// flash on every 10s in-progress poll.
reviewsExpanding: [
false,
{
showMoreReviews: () => true,
showFewerReviews: () => false,
loadRecentReviewsSuccess: () => false,
loadRecentReviewsFailure: () => false,
},
],
// The page-level "For you / Entire project" switch (mirroring the inbox's): it scopes the
// recent-reviews list AND every stat surface fed by perspectiveStats (hero proof card,
// effectiveness cards). Skill lists and their toggles stay per-user regardless.
reviewsScope: [
ReviewReviewsListScope.Mine as ReviewReviewsListScope,
{ persist: true },
{
setReviewsScope: (_, { scope }) => scope,
applyDefaultReviewsScope: (_, { scope }) => scope,
},
],
// Whether the user has explicitly picked a scope. Once true, the empty-list auto-default
// no longer fires, so a deliberate choice of "For you" is respected even with zero reviews.
// A shared link is an explicit choice too, so URL hydration goes through setReviewsScope.
hasUserChosenReviewsScope: [
false,
{ persist: true },
{
setReviewsScope: () => true,
},
],
initialLoadFailed: [
false,
{
loadAll: () => false,
loadSettingsFailure: () => true,
loadPerspectivesFailure: () => true,
loadBlindSpotsFailure: () => true,
loadValidatorsFailure: () => true,
// recentReviews/perspectiveStats stay null on failure and their sections render
// skeletons while null — without these the skeletons are permanent, with no retry.
loadRecentReviewsFailure: () => true,
loadPerspectiveStatsFailure: () => true,
},
],
creatingSkillKind: [
// Guards the "Create your own …" buttons against double-submission while the task spins up.
null as ReviewSkillKind | null,
{
startSkillAuthorTask: (_, { kind }) => kind,
startSkillAuthorTaskFinished: () => null,
},
],
triggerPrUrl: [
'',
{
setTriggerPrUrl: (_, { prUrl }) => prUrl,
},
],
triggeringReview: [
false,
{
// Flipped by the listener (not the submit action itself) so the listener can tell a
// first submit from an Enter-spam repeat and drop the repeat before it POSTs.
submitTriggerReviewStarted: () => true,
submitTriggerReviewFinished: () => false,
},
],
awaitingTriggeredReview: [
false,
{
startTriggeredReviewWatch: () => true,
stopTriggeredReviewWatch: () => false,
},
],
}),
selectors({
recentReviews: [
(s) => [s.recentReviewsPage],
(recentReviewsPage: ReviewRecentReviewsPageApi | null): ReviewRecentReviewApi[] | null =>
recentReviewsPage?.results ?? null,
],
moreReviewsAvailable: [
(s) => [s.recentReviewsPage, s.reviewsLimit],
(recentReviewsPage: ReviewRecentReviewsPageApi | null, reviewsLimit: number): boolean =>
// At the API's ceiling the button must go away even though more rows exist —
// offering it would send a limit the server rejects.
(recentReviewsPage?.has_more ?? false) && reviewsLimit < MAX_REVIEWS_LIMIT,
],
// Splits the detail's valid findings by the threshold the run actually gated on
// (`run_urgency_threshold`, stamped at finalize). Only rows that predate the stamp fall
// back to the viewer's current setting — an approximation that can misbucket when the
// run's acting user or their settings differ from the viewer's.
reviewFindingsSplit: [
(s) => [s.reviewDetail, s.settings],
(
reviewDetail: ReviewDetailApi | null,
settings: ReviewUserSettingsApi | null
): ReviewFindingsSplit | null => {
if (!reviewDetail) {
return null
}
const threshold = reviewDetail.run_urgency_threshold ?? settings?.urgency_threshold ?? 'consider'
const thresholdRank = REVIEW_PRIORITY_RANK[threshold]
return {
published: reviewDetail.findings.filter(
(f) => REVIEW_PRIORITY_RANK[f.effective_priority] >= thresholdRank
),
belowThreshold: reviewDetail.findings.filter(
(f) => REVIEW_PRIORITY_RANK[f.effective_priority] < thresholdRank
),
}
},
],
perspectiveScoreboard: [
(s) => [s.reviewDetail],
(reviewDetail: ReviewDetailApi | null): PerspectiveScore[] | null => {
if (!reviewDetail?.findings.length) {
return null
}
const counts = new Map<string, number>()
for (const finding of reviewDetail.findings) {
const skillName = finding.source_perspective ?? 'unknown'
counts.set(skillName, (counts.get(skillName) ?? 0) + 1)
}
return Array.from(counts, ([skillName, count]) => ({ skillName, count })).sort(
(a, b) => b.count - a.count
)
},
],
}),
listeners(({ actions, values, cache }) => ({
// Poll while any review is running so the stage row moves — or while a just-triggered
// review's row hasn't appeared yet; the disposable auto-pauses on hidden tabs and is torn
// down on unmount.
loadRecentReviewsSuccess: () => {
const anyInProgress = values.recentReviews?.some((review) => review.in_progress) ?? false
// The watch deliberately runs its full bounded window instead of stopping when an
// in-progress row shows up: an unrelated already-running review would satisfy that check
// and could finish before the triggered row appears, killing the poll too early. The
// cost is at most the watch window of idle 10s polls after a fast run completes.
if (anyInProgress || values.awaitingTriggeredReview) {
cache.disposables.add(() => {
const pollTimer = window.setInterval(
() => actions.loadRecentReviews(),
IN_PROGRESS_POLL_INTERVAL_MS
)
return () => clearInterval(pollTimer)
}, 'inProgressPoll')
} else {
cache.disposables.dispose('inProgressPoll')
}
// No reviews of the user's own PRs: default to the whole project so the block isn't
// empty — only until the user picks a scope themselves.
if (
!values.recentReviews?.length &&
values.reviewsScope === ReviewReviewsListScope.Mine &&
!values.hasUserChosenReviewsScope
) {
actions.applyDefaultReviewsScope(ReviewReviewsListScope.Everyone)
}
},
startTriggeredReviewWatch: () => {
// Bounded: a run that dies before creating its report row must not poll forever. The
// next list load after expiry sees no in-progress rows and disposes the poll itself.
cache.disposables.add(() => {
const expiryTimer = window.setTimeout(
() => actions.stopTriggeredReviewWatch(),
TRIGGERED_REVIEW_WATCH_TIMEOUT_MS
)
return () => clearTimeout(expiryTimer)
}, 'triggeredReviewWatch')
},
stopTriggeredReviewWatch: () => {
cache.disposables.dispose('triggeredReviewWatch')
},
setReviewsScope: () => {
actions.loadRecentReviews()
actions.loadPerspectiveStats()
},
applyDefaultReviewsScope: () => {
actions.loadRecentReviews()
actions.loadPerspectiveStats()
},
showMoreReviews: () => actions.loadRecentReviews(),
showFewerReviews: () => actions.loadRecentReviews(),
loadAll: () => {
actions.loadSettings()
actions.loadPerspectives()
actions.loadBlindSpots()
actions.loadValidators()
actions.loadRecentReviews()
actions.loadPerspectiveStats()
},
updateSettingsFailure: () => {
// The global loaders toast already surfaced the error; just reconcile the optimistic state.
actions.loadSettings()
},
togglePerspective: async ({ skillName, enabled }) => {
// Min-1 floor, mirrored from the backend so the block is instant (the server still 400s).
const enabledCount = values.perspectives?.filter((p) => p.enabled).length ?? 0
if (!enabled && enabledCount <= 1) {
toast.info('Keep at least one perspective enabled')
return
}
actions.patchPerspectiveLocally(skillName, enabled)
actions.setSkillSaving(skillName, true)
try {
await reviewPerspectivesPartialUpdate(currentProjectId(), skillName, { enabled })
} catch (error: any) {
// `data?.[0]`: DRF renders bare-string ValidationErrors (e.g. the min-1 floor) as a list.
toast.error(
error?.detail || error?.data?.[0] || error?.message || 'Failed to update the perspective'
)
actions.loadPerspectives()
} finally {
actions.setSkillSaving(skillName, false)
}
},
selectBlindSpots: async ({ skillName }) => {
actions.setSkillSaving(skillName, true)
try {
await reviewBlindSpotsPartialUpdate(currentProjectId(), skillName, { active: true })
} catch (error: any) {
toast.error(
error?.detail || error?.data?.[0] || error?.message || 'Failed to select the blind-spot check'
)
actions.loadBlindSpots()
} finally {
actions.setSkillSaving(skillName, false)
}
},
selectValidator: async ({ skillName }) => {
actions.setSkillSaving(skillName, true)
try {
await reviewValidatorsPartialUpdate(currentProjectId(), skillName, { active: true })
} catch (error: any) {
toast.error(
error?.detail || error?.data?.[0] || error?.message || 'Failed to select the validation criteria'
)
actions.loadValidators()
} finally {
actions.setSkillSaving(skillName, false)
}
},
blockSingleActiveDeactivation: ({ kindLabel }) => {
toast.info(`One ${kindLabel} always runs — switch by selecting another one`)
},
openReviewDetail: ({ review }) => {
actions.loadReviewDetail(review.id)
},
openReviewDetailById: ({ reviewId }) => {
actions.loadReviewDetail(reviewId)
},
loadReviewDetailFailure: () => {
// Only the deep-link path has no list row keeping the drawer meaningful — a failed
// load there (stale link, deleted report) would strand an open drawer of skeletons.
if (values.reviewDrawerOpen && !values.openedReview) {
toast.error("That review couldn't be opened. The link may be stale, or the review deleted.")
actions.closeReviewDrawer()
}
},
submitTriggerReview: async () => {
if (values.triggeringReview) {
// A request is already in flight — the disabled button can't stop an Enter keypress
// in the input, so the guard lives here, covering every dispatch source.
return
}
const prUrl = values.triggerPrUrl.trim()
if (!prUrl) {
return
}
actions.submitTriggerReviewStarted()
try {
const response = await reviewReviewsTriggerCreate(currentProjectId(), { pr_url: prUrl })
actions.setTriggerPrUrl('')
if (response.status === 'already_reviewed') {
// No run started — the PR's current commit already has a published review.
toast.info(
'This pull request was already reviewed at its current commit. Find it under recent reviews.'
)
} else {
toast.success('Review started. It will appear under recent reviews as it runs.')
// The review's report row is created seconds later by the workflow's fetch step,
// so one immediate reload usually misses it — arm the watch before reloading.
actions.startTriggeredReviewWatch()
}
actions.loadRecentReviews()
} catch (error: any) {
// The trigger endpoint's rejections come back as `{error: "..."}` bodies.
toast.error(error?.data?.error || error?.detail || error?.message || 'Failed to start the review')
} finally {
actions.submitTriggerReviewFinished()
}
},
startSkillAuthorTask: async ({ kind }) => {
// Task-kickoff mirroring the Inbox "Make a scout" flow: create an agent task from a
// templated authoring prompt, then navigate to it. Not a live chat.
const { title, prompt } = SKILL_AUTHOR_TASKS[kind]
try {
let repository: string | undefined
try {
const { repositories } = await api.tasks.repositories()
repository = repositories[0]
} catch {
repository = undefined
}
const task = await api.tasks.create({
title,
description: prompt,
origin_product: OriginProduct.USER_CREATED,
repository,
})
router.actions.push(urls.taskDetail(task.id))
} catch (error: any) {
toast.error(error?.detail || error?.message || `Failed to start "${title}"`)
} finally {
actions.startSkillAuthorTaskFinished()
}
},
})),
// An explicit scope pick is mirrored to the URL (`?reviews_scope=everyone`) so a specific view
// can be shared via a link; the default scope keeps the URL clean. The auto-default deliberately
// does NOT write the URL: hydrating from a link marks the scope as chosen (below), so mirroring
// the fallback would silently upgrade it into a permanent explicit choice on reload.
// The drawer mirrors to `?review=<report id>` the same way — a PERMANENT PUBLIC CONTRACT (PR
// status comments bake these links into GitHub, where they are never re-edited). Always
// `replace`, never push, so opening/closing the drawer doesn't stack history entries.
actionToUrl(({ values }) => {
const withReviewParam = (
review: string | undefined
): [string, Record<string, any>, Record<string, any>, { replace: boolean }] => [
router.values.location.pathname,
{ ...router.values.searchParams, review },
router.values.hashParams,
{ replace: true },
]
return {
setReviewsScope: (): [string, Record<string, any>, Record<string, any>, { replace: boolean }] => [
router.values.location.pathname,
{
...router.values.searchParams,
reviews_scope:
values.reviewsScope === ReviewReviewsListScope.Mine ? undefined : values.reviewsScope,
},
router.values.hashParams,
{ replace: true },
],
openReviewDetail: ({ review }) => withReviewParam(review.id),
openReviewDetailById: ({ reviewId }) => withReviewParam(reviewId),
closeReviewDrawer: () => withReviewParam(undefined),
}
}),
urlToAction(({ actions, values }) => ({
[urls.codeReview()]: (_, searchParams) => {
const parsed = searchParams.reviews_scope
if (
(parsed === ReviewReviewsListScope.Mine || parsed === ReviewReviewsListScope.Everyone) &&
parsed !== values.reviewsScope
) {
actions.setReviewsScope(parsed)
}
// ?review=<report id>: open that review's drawer. `openedReviewId` (set synchronously by
// both open paths) makes the guard hold before the detail loads, so the open's own URL
// write-back — a repeat location event with the same param — can't re-dispatch forever.
const reviewParam = searchParams.review
if (typeof reviewParam === 'string' && reviewParam && reviewParam !== values.openedReviewId) {
actions.openReviewDetailById(reviewParam)
} else if (!reviewParam && values.reviewDrawerOpen) {
// Navigation that drops the param (back/forward, a pasted URL without it) closes
// the drawer — otherwise the URL and the visible report disagree.
actions.closeReviewDrawer()
}
},
})),
afterMount(({ actions }) => {
actions.loadAll()
}),
])