diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx
new file mode 100644
index 0000000000..24845a8d1d
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx
@@ -0,0 +1,9 @@
+import {
+ DetailMainSkeleton,
+ LoadingShell,
+} from '../_components/LoadingShell';
+
+/** Detail-route loading state. Mobile shows ONLY the detail-shape main pane. */
+export default function Loading() {
+ return } />;
+}
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx
index 4abd99ed18..b2b69b136a 100644
--- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx
@@ -43,7 +43,7 @@ export function CompletedDetail({
return (
-
+
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx
index 3a83908c54..ab3e054742 100644
--- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx
@@ -46,7 +46,7 @@ export function EmptyState({
? "You've used your trial run. Paid plans are coming soon — contact support if you need access today."
: 'Automated black-box pen testing. Start a scan to see findings here.';
return (
-
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx
index 5aa3d849ec..67f7407fd8 100644
--- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx
@@ -1,7 +1,8 @@
'use client';
import { Button } from '@trycompai/design-system';
-import { Add } from '@trycompai/design-system/icons';
+import { Add, ArrowRight } from '@trycompai/design-system/icons';
+import { useRouter } from 'next/navigation';
import type { PentestRun } from '@/lib/security/penetration-tests-client';
import {
LatestAssessment,
@@ -17,6 +18,8 @@ import {
sortByUpdatedDesc,
uniqueTargets,
} from './overview-internals';
+import { StatusPill } from './StatusPill';
+import { isRunInProgress } from './severity';
interface OverviewPaneProps {
orgId: string;
@@ -29,10 +32,13 @@ interface OverviewPaneProps {
}
/**
- * Right pane shown when no scan is selected. Two real states:
+ * Right pane shown when no scan is selected. Three real states:
*
- * - 0 completed scans → onboarding card with primary CTA
- * - 1+ completed scans → posture overview: real counts, recent scans,
+ * - 0 completed, 0 in-progress → onboarding card with primary CTA
+ * - 0 completed, 1+ in-progress → in-progress card surfacing the running
+ * scan(s); avoids the "No scans yet" lie when the sidebar already
+ * shows a running scan.
+ * - 1+ completed → posture overview: real counts, recent scans,
* stale-coverage list. NO cross-scan severity aggregation, NO trend
* chart, NO "open findings" queue — those need per-run issue counts
* in the list endpoint (backend aggregation), which we don't have
@@ -50,6 +56,22 @@ export function OverviewPane({
const completed = runs.filter((r) => r.status === 'completed');
if (completed.length === 0) {
+ // If there are no completed runs but a scan is currently running, surface
+ // it instead of the "No scans yet" onboarding — that headline contradicts
+ // the sidebar (which already shows the running scan) and was the source
+ // of a customer report: "I left, came back, it says no scans yet instead
+ // of just showing the latest one which had already started."
+ const inProgress = runs.filter((r) => isRunInProgress(r.status));
+ if (inProgress.length > 0) {
+ return (
+
+ );
+ }
return ;
}
@@ -66,6 +88,106 @@ export function OverviewPane({
);
}
+interface InProgressStateProps {
+ orgId: string;
+ runs: PentestRun[];
+ onCreateClick: () => void;
+ canCreate: boolean;
+}
+
+/**
+ * Shown on `/pentests` when the org has only in-progress scans (no
+ * completed history yet). Lists the running scans with a click-to-view
+ * card so the user lands on something that matches the sidebar instead
+ * of the onboarding empty state.
+ */
+function InProgressState({
+ orgId,
+ runs,
+ onCreateClick,
+ canCreate,
+}: InProgressStateProps) {
+ const router = useRouter();
+ const sorted = [...runs].sort(
+ (a, b) =>
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
+ );
+ const headline =
+ sorted.length === 1 ? 'Scan in progress' : `${sorted.length} scans in progress`;
+
+ return (
+
+
+
+ Penetration tests · Overview
+
+
+ {headline}
+
+
+ Findings stream in as agents discover them. You don't need to keep
+ this page open — open the run any time to see live progress.
+
Penetration tests · Overview
@@ -128,21 +250,26 @@ function PostureOverview({
onDownloadMarkdown,
onDownloadPdf,
}: PostureOverviewProps) {
- // Coverage and stale-target stats use ONLY completed runs — a target
- // that's only ever had failed/cancelled scans isn't truly "covered,"
- // and a target whose latest scan failed shouldn't reset the staleness
- // clock. The full `runs` list is only used for the recent activity
- // sidebar elsewhere.
+ // Coverage / avg-duration / stale stats are completed-only on purpose:
+ // a target whose only scans are running or failed isn't actually
+ // "covered," a running scan has no real duration yet, and a failed
+ // scan shouldn't reset the staleness clock.
+ //
+ // "Recent scans" is the exception — that's an activity feed, not a
+ // success metric, so it uses the full `runs` list. Otherwise a user
+ // with completed history but a fresh running scan would see the
+ // running one in the sidebar but NOT here, which contradicts the
+ // sidebar and hides the live scan from the overview.
const targets = uniqueTargets(completed);
const lastScan = mostRecent(completed);
const avgDuration = avgDurationMs(completed);
const scansLast30d = countWithin(completed, 30 * 24 * 60 * 60 * 1000);
- const recentScans = sortByUpdatedDesc(completed).slice(0, 6);
+ const recentScans = sortByUpdatedDesc(runs).slice(0, 6);
const staleTargets = computeStaleTargets(completed, targets);
return (
-
+
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx
index 3cb7023000..2f0d84f96e 100644
--- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx
@@ -36,7 +36,7 @@ export function RunList({
: 'No pentest runs remaining.'
: 'Start a new scan';
return (
-