Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/06-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ Features:
Available to users with `REVIEWER` or `ADMIN` role:

- Paginated list of queries in `PENDING_REVIEW` status assigned to this reviewer, rendered as an Ant Design `<Table>` with `rowSelection` so the reviewer can drive both single-row and batch flows from the same page.
- Columns: ID (short hash + full UUID + tooltip), query type, AI risk badge, datasource, submitter (avatar + email), time elapsed, optional per-row status badge for failed bulk rows, and a row-actions column with per-row approve/reject buttons.
- Columns: ID (short hash + full UUID + tooltip), query type, AI risk badge, approval likelihood, datasource, submitter (avatar + email), time elapsed, optional per-row status badge for failed bulk rows, and a row-actions column with per-row approve/reject buttons.
- The **approval-likelihood** column (AF-645) renders `approval_probability` as a neutral percentage badge (`components/review/ApprovalPredictionBadge.tsx`, tooltip explaining it is an advisory statistical estimate) and a muted `—` when the row is unscored or its persisted prediction is a skipped / failed sentinel. The queue payload carries only `approval_probability` and no skip reason, so that dash's tooltip points at the detail page ("No approval likelihood for this query. Open it to see why.") rather than guessing which of the sentinels applies.
- Quick approve inline on the row. Reject opens `RejectModal` ([components/review/RejectModal.tsx](../frontend/src/components/review/RejectModal.tsx)) — a comment is required (the confirm button stays disabled until the textarea is non-whitespace), mirroring the backend `@NotBlank` constraint on `POST /reviews/{id}/reject`.
- Selecting one or more rows reveals a **sticky action bar** above the table with "Approve selected", "Reject selected", "Request changes", and "Clear selection". Each button opens the shared `BulkDecisionModal` ([components/review/BulkDecisionModal.tsx](../frontend/src/components/review/BulkDecisionModal.tsx)), which collects one comment to apply to every selected query and submits to `POST /api/v1/reviews/bulk`. After submit, successful rows leave the queue; failed rows stay selected with a per-row status tag (Forbidden / Not pending review / Not found) so the reviewer can retry.
- Row click opens the full detail page (`/queries/:id`); the row-actions column buttons stop propagation so they don't trigger navigation.
Expand Down Expand Up @@ -241,6 +242,7 @@ Full detail view for any query:
- When `ai_analysis.failed === true` (AF-249), a warning `Alert` at the top of the main column tells the reviewer that AI analysis didn't complete and that review is proceeding without an AI recommendation; the analyzer's reason is shown both in the banner detail and in a dedicated failure variant of the AI accordion. The `RiskPill` in the accordion header switches to a neutral grey "AI N/A" variant (`failed` prop on `RiskPill`). For `REVIEWER` / `ADMIN` callers a primary "Re-analyze" button (in both the banner and the accordion) calls `POST /queries/{id}/reanalyze`; the page invalidates its TanStack Query entries on success and picks up the new analysis via the existing `ai.analysis_complete` WebSocket event. The list page (`QueryListPage`) renders the same "AI N/A" pill in the risk column when `ai_failed=true` on the list row, so a CRITICAL-looking sentinel is never mistaken for a real risk verdict.
- When the latest entry in `review_decisions[]` has `decision: REQUESTED_CHANGES` AND the query is still `PENDING_REVIEW` (AF-269), an info `Alert` at the top of the main column tells the submitter that the reviewer asked for changes — body interpolates `{{reviewer}}`, `{{when}}`, and `{{comment}}`. The reviewer decision panel itself requires a non-empty comment for both **Reject** (disabled until typed) and **Request changes** (already disabled); approving still allows an empty comment. The rejected stage of `ApprovalTimeline` carries the last `REJECTED` decision's comment (wrapped in `"…"` so the existing italic style in [ApprovalTimeline.tsx](../frontend/src/components/review/ApprovalTimeline.tsx) applies).
- **Cost-estimate card** (AF-624) — a "Cost estimate" `DetailCard` (`components/review/CostEstimatePanel.tsx`) renders the query's persisted pre-flight blast-radius estimate from `cost_estimate` on `GET /queries/{id}`: the exact affected-row count for UPDATE/DELETE ("Affected rows (exact)"), the plan's estimated rows / scan type / cost, and the execution-plan tree (reusing the editor's `PlanTree` + `utils/queryPlan.ts`), falling back to the raw plan text. State machine mirrors the AI card: while `status === 'PENDING_AI'` and `cost_estimate` is null it shows "Computing the cost estimate…"; a null estimate past that shows "No cost estimate is available for this query."; `supported=false` renders the localized `unsupported_reason` (still showing the exact count when one was computed); `failed=true` renders a warning with `error_message`. The `query.estimate_complete` WebSocket event invalidates `['queries','detail',id]` so the panel fills in without polling.
- **Approval-likelihood card** (AF-645) — rendered **only for viewers holding `QUERY_REVIEW`** (the same `isReviewer` gate as the decision panel): the prediction is a triage aid for whoever decides, and showing a submitter how their peers are likely to vote on their own open request would invite cancel-and-resubmit gaming. The backend serves the field to any authorized reader, so this is a client-side product decision. An "Approval likelihood" `DetailCard` (`components/review/ApprovalPredictionPanel.tsx`) renders the advisory approval-outcome prediction from `approval_prediction` on `GET /queries/{id}`: the label "Historical approval likelihood" next to the probability as a rounded percentage (`components/review/ApprovalPredictionBadge.tsx`), plus a permanent note — "Statistical estimate based on this organization's past review decisions. Advisory only — it never approves or rejects anything." States: with no row yet it shows "Computing the approval likelihood…" while `status === 'PENDING_AI'`, or while `PENDING_REVIEW` and the query's `updated_at` is inside a five-minute grace window — scoring fires off the transition into review and takes seconds, so past that the row is never coming (a query that predates the feature, or a listener that never ran) and the copy switches to "No approval likelihood is available for this query."; `failed=true` renders a warning; `skipped=true` — or any row that carries no probability — renders an info notice with the **client-localized** `skipped_reason` machine token (`DISABLED` → "…switched off for this organization.", `MODEL_NOT_SERVING` → "Not enough review history yet.", any other/unknown token → the generic unavailable copy, never the raw token; the token set is modelled as the `ApprovalPredictionSkipReason` union in `types/api.ts` so a new backend token breaks the lookup map at compile time). The badge deliberately uses the neutral `--fg-muted` / `--bg-sunken` / `--border` tokens rather than the `--risk-*` palette, and all copy is strictly non-directive — the number is a triage signal, never a recommendation to approve or reject. The `query.prediction_complete` WebSocket event invalidates `['queries','detail',id]` and `['reviews','pending']` so both the card and the queue column fill in without polling.
- When `ai_analysis === null` and the query has already advanced out of `PENDING_AI` (AF-307), the AI step is rendered as **bypassed** rather than waiting. The card title becomes "AI analysis (skipped)" with a muted body — "AI analysis was skipped — this datasource has AI analysis disabled." — and the `ApprovalTimeline` shows a gray stage labeled "AI analysis skipped" (dot uses `--fg-muted`). The skipped state is derived on the frontend (`!ai_analysis && status !== 'PENDING_AI'`); the backend persists no `ai_analyses` row on the skip path. While the query is still in `PENDING_AI`, the original "Awaiting analysis…" fallback continues to render.

### DatasourceCreateWizardPage *(ADMIN)*
Expand Down Expand Up @@ -654,6 +656,7 @@ useEffect(() =>
| `query.executed` | `['queries','detail',query_id]` and `['queries','list']` |
| `ai.analysis_complete` | `['queries','detail',query_id]` |
| `query.estimate_complete` | `['queries','detail',query_id]` |
| `query.prediction_complete` | `['queries','detail',query_id]` and `['reviews','pending']` (AF-645) |
| `review.new_request` | `['reviews','pending']` |
| `review.decision_made` | `['reviews','pending']` and `['queries','detail',query_id]` |
| `notification.created` | `['notifications','list']` and `['notifications','unread-count']` |
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/components/review/ApprovalPredictionBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@/i18n';
import { ApprovalPredictionBadge } from './ApprovalPredictionBadge';

describe('ApprovalPredictionBadge', () => {
it('renders the probability as a rounded percentage', () => {
render(<ApprovalPredictionBadge probability={0.784} />);
expect(screen.getByTestId('approval-prediction-badge')).toHaveTextContent('78%');
});

it('renders nothing when the probability is null', () => {
const { container } = render(<ApprovalPredictionBadge probability={null} />);
expect(container).toBeEmptyDOMElement();
});

it('renders nothing when the probability is absent', () => {
const { container } = render(<ApprovalPredictionBadge />);
expect(container).toBeEmptyDOMElement();
});

it('renders a zero probability rather than treating it as absent', () => {
render(<ApprovalPredictionBadge probability={0} size="sm" />);
const badge = screen.getByTestId('approval-prediction-badge');
expect(badge).toHaveTextContent('0%');
expect(badge).toHaveClass('af-pill-sm');
});

it('uses neutral theme tokens rather than the risk palette', () => {
render(<ApprovalPredictionBadge probability={0.5} />);
const badge = screen.getByTestId('approval-prediction-badge');
expect(badge.getAttribute('style')).toContain('var(--fg-muted)');
expect(badge.getAttribute('style')).not.toContain('--risk');
});
});
36 changes: 36 additions & 0 deletions frontend/src/components/review/ApprovalPredictionBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';

interface ApprovalPredictionBadgeProps {
/** Advisory approval probability in [0,1]; null/undefined renders nothing. */
probability?: number | null;
size?: 'sm' | 'md';
}

/**
* Compact approval-likelihood badge for review-queue rows (AF-645). Deliberately neutral: it uses
* the muted palette rather than the risk tokens, because the number is a triage signal — not a risk
* level and not a recommendation to approve or reject.
*/
export function ApprovalPredictionBadge({ probability, size = 'md' }: ApprovalPredictionBadgeProps) {
const { t } = useTranslation();

if (probability === null || probability === undefined) return null;

const percent = Math.round(probability * 100);
return (
<Tooltip title={t('approval_prediction.tooltip')}>
<span
className={`af-pill af-pill-${size}`}
style={{
color: 'var(--fg-muted)',
background: 'var(--bg-sunken)',
borderColor: 'var(--border)',
}}
data-testid="approval-prediction-badge"
>
{t('approval_prediction.badge_value', { percent })}
</span>
</Tooltip>
);
}
124 changes: 124 additions & 0 deletions frontend/src/components/review/ApprovalPredictionPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@/i18n';
import type { ApprovalPredictionDetail, ApprovalPredictionSkipReason } from '@/types/api';
import { ApprovalPredictionPanel } from './ApprovalPredictionPanel';

function prediction(overrides: Partial<ApprovalPredictionDetail> = {}): ApprovalPredictionDetail {
return {
id: 'ap-1',
probability: 0.78,
skipped: false,
skipped_reason: null,
failed: false,
created_at: '2026-08-01T10:00:00Z',
...overrides,
};
}

describe('ApprovalPredictionPanel', () => {
it('shows a pending message during the async scoring gap', () => {
render(
<ApprovalPredictionPanel
prediction={null}
status="PENDING_REVIEW"
updatedAt={new Date(Date.now() - 5_000).toISOString()}
/>,
);
expect(screen.getByText(/computing the approval likelihood/i)).toBeInTheDocument();
});

it('shows a pending message while the query has not reached review yet', () => {
render(<ApprovalPredictionPanel prediction={null} status="PENDING_AI" />);
expect(screen.getByText(/computing the approval likelihood/i)).toBeInTheDocument();
});

it('stops promising a pending score once the grace window has elapsed', () => {
render(
<ApprovalPredictionPanel
prediction={null}
status="PENDING_REVIEW"
updatedAt="2026-05-01T10:00:00Z"
/>,
);
expect(screen.getByText(/no approval likelihood is available/i)).toBeInTheDocument();
});

it('keeps the pending copy when the timestamp is unusable', () => {
render(
<ApprovalPredictionPanel prediction={null} status="PENDING_REVIEW" updatedAt="not-a-date" />,
);
expect(screen.getByText(/computing the approval likelihood/i)).toBeInTheDocument();
});

it('shows an unavailable message once the query is terminal and no row landed', () => {
render(<ApprovalPredictionPanel status="EXECUTED" />);
expect(screen.getByText(/no approval likelihood is available/i)).toBeInTheDocument();
});

it('renders the percentage and the advisory note on the happy path', () => {
render(<ApprovalPredictionPanel prediction={prediction()} status="PENDING_REVIEW" />);
expect(screen.getByText(/historical approval likelihood/i)).toBeInTheDocument();
expect(screen.getByTestId('approval-prediction-badge')).toHaveTextContent('78%');
expect(screen.getByText(/advisory only/i)).toBeInTheDocument();
});

it('renders the failure sentinel', () => {
render(
<ApprovalPredictionPanel
prediction={prediction({ probability: null, failed: true })}
status="PENDING_REVIEW"
/>,
);
expect(screen.getByRole('status')).toHaveTextContent(/could not be computed/i);
});

it('localizes the DISABLED skip reason', () => {
render(
<ApprovalPredictionPanel
prediction={prediction({ probability: null, skipped: true, skipped_reason: 'DISABLED' })}
status="PENDING_REVIEW"
/>,
);
expect(screen.getByRole('status')).toHaveTextContent(/switched off for this organization/i);
});

it('localizes the MODEL_NOT_SERVING skip reason', () => {
render(
<ApprovalPredictionPanel
prediction={prediction({
probability: null,
skipped: true,
skipped_reason: 'MODEL_NOT_SERVING',
})}
status="PENDING_REVIEW"
/>,
);
expect(screen.getByRole('status')).toHaveTextContent(/not enough review history yet/i);
});

it('falls back to a generic message for a token the client does not know', () => {
// The type union is closed, but the server can ship a new token ahead of the frontend —
// the raw machine token must never reach the DOM.
const unknownToken = 'SOMETHING_NEW' as ApprovalPredictionSkipReason;
render(
<ApprovalPredictionPanel
prediction={prediction({ probability: null, skipped: true, skipped_reason: unknownToken })}
status="PENDING_REVIEW"
/>,
);
const notice = screen.getByRole('status');
expect(notice).toHaveTextContent(/no approval likelihood is available/i);
expect(notice).not.toHaveTextContent('SOMETHING_NEW');
});

it('falls back to the skipped notice when a non-sentinel row carries no probability', () => {
render(
<ApprovalPredictionPanel
prediction={prediction({ probability: null })}
status="PENDING_REVIEW"
/>,
);
expect(screen.getByRole('status')).toHaveTextContent(/no approval likelihood is available/i);
});
});
Loading
Loading