From 5e1676a73a93cc217921ce7c1314932f0e8165d9 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Sun, 26 Jul 2026 21:51:44 +0100 Subject: [PATCH] feat(frontend): lead the repository landing view with an outcome-first summary The repository primary view, Dependency Graph, and Engineering Review all led with feature/provenance chrome instead of the outcome a user actually needs: ~10 equally-weighted nav destinations, and Snapshot ID / Manifest Digest / Canonical Graph Hash in large mono caps as the first thing on two pages. Add RepositoryOutcomeSummary, a concise stack/structure/dependencies/headline summary composed entirely from the existing Architecture, Review, Dependencies and Insights read APIs (no LLM, no client-side scoring) with an explicit "not assessed" state per section when its snapshot data isn't available. It leads the repository detail page, ahead of the Overview/Explorer tabs. Snapshot/manifest identity is reachable one click away via "Verify", which reveals the existing RevisionManifestPanel -- the same collapsed-by-default provenance panel Architecture already uses, now reused on Dependency Graph and Engineering Review too instead of an always-visible identity grid. Sidebar navigation now groups Dashboard/Repositories/Upload/Architecture as the flagship workflow, with the remaining surfaces under a lighter-weight "More" section -- all ten destinations stay fully reachable, just no longer competing equally for attention. --- .../src/app/pages/DependenciesPage.test.tsx | 47 +++- .../src/app/pages/DependenciesPage.tsx | 19 +- .../app/pages/EngineeringReviewPage.test.tsx | 134 +++++++++++ .../src/app/pages/EngineeringReviewPage.tsx | 19 +- .../app/pages/RepositoryDetailPage.test.tsx | 77 ++++++ .../src/app/pages/RepositoryDetailPage.tsx | 3 + .../src/app/routes/productSurfaces.tsx | 19 ++ .../RepositoryOutcomeSummary.test.tsx | 224 ++++++++++++++++++ .../components/RepositoryOutcomeSummary.tsx | 149 ++++++++++++ .../hooks/useRepositoryOutcomeSummary.test.ts | 215 +++++++++++++++++ .../hooks/useRepositoryOutcomeSummary.ts | 133 +++++++++++ .../shared/components/layout/Sidebar.test.tsx | 29 +++ .../src/shared/components/layout/Sidebar.tsx | 127 +++++++--- 13 files changed, 1124 insertions(+), 71 deletions(-) create mode 100644 apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx create mode 100644 apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx create mode 100644 apps/frontend/src/features/repositories/components/RepositoryOutcomeSummary.test.tsx create mode 100644 apps/frontend/src/features/repositories/components/RepositoryOutcomeSummary.tsx create mode 100644 apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.test.ts create mode 100644 apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.ts diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx index e895d53..7f4284b 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.test.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -1,14 +1,41 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useDependencies } from '@/features/dependencies/hooks/useDependencies'; -import type { DependencyDiagnostic, DependencyGraphResponse } from '@/shared/services/api/types'; +import { architectureService } from '@/shared/services/api/architecture'; +import type { DependencyDiagnostic, DependencyGraphResponse, RevisionManifestResponse } from '@/shared/services/api/types'; import { DependenciesPage } from './DependenciesPage'; vi.mock('@/features/dependencies/hooks/useDependencies', () => ({ useDependencies: vi.fn(), })); +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { getRevisionManifest: vi.fn() }, +})); + +const revisionManifest: RevisionManifestResponse = { + manifest: { + schemaVersion: 'revision-manifest.v1', + repositoryId: 'repo-1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + revisionRef: null, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + extractors: [{ name: 'dependency-manifest', version: '1.2.0' }], + producerSetHash: `sha256:${'1'.repeat(64)}`, + configHash: `sha256:${'2'.repeat(64)}`, + canonicalGraphHash: `sha256:${'3'.repeat(64)}`, + createdAt: '2026-07-25T00:00:00Z', + sealedAt: '2026-07-25T00:00:05Z', + }, + manifestDigest: `sha256:${'4'.repeat(64)}`, + verificationMethod: 'sha256-canonical-json', + verificationState: 'verified', + verificationNote: 'This digest is a SHA-256 over the canonical JSON encoding of the manifest fields above.', +}; + const graph: DependencyGraphResponse = { schemaVersion: 'dependency-graph.v2', repositoryId: 'repo-1', @@ -124,6 +151,7 @@ function diagnostic(severity: DependencyDiagnostic['severity']): DependencyDiagn describe('DependenciesPage', () => { beforeEach(() => { mockDependencies(); + vi.mocked(architectureService.getRevisionManifest).mockResolvedValue(revisionManifest); }); it('shows uncomputed assessments without clean badges or numeric fallbacks', () => { @@ -136,14 +164,21 @@ describe('DependenciesPage', () => { expect(screen.queryByText('outdated')).not.toBeInTheDocument(); }); - it('renders the sealed snapshot identity, consistent with Architecture/Review', () => { + it('keeps the sealed snapshot identity one click away instead of on the landing strip (#176)', async () => { renderPage(); - expect(screen.getByText('Snapshot')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + // Collapsed by default: the snapshot id stays visible, but the hashes do not. expect(screen.getByText('snap_example')).toBeInTheDocument(); - expect(screen.getByText('Canonical graph hash')).toBeInTheDocument(); - expect(screen.getByText(graph.canonicalGraphHash)).toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifestDigest)).not.toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifest.canonicalGraphHash!)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /details/i })); + expect(screen.getByText('Manifest digest')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifestDigest)).toBeInTheDocument(); + expect(screen.getByText('Canonical graph hash')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifest.canonicalGraphHash!)).toBeInTheDocument(); }); it('shows an empty inventory instead of a search-miss message', () => { diff --git a/apps/frontend/src/app/pages/DependenciesPage.tsx b/apps/frontend/src/app/pages/DependenciesPage.tsx index 63289b1..80036fb 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.tsx @@ -6,6 +6,7 @@ import { EmptyState } from '@/shared/components/ui/EmptyState'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { ExportMenu } from '@/shared/components/ui/ExportMenu'; import { useDependencies } from '@/features/dependencies/hooks/useDependencies'; +import { RevisionManifestPanel } from '@/features/architecture/components/RevisionManifestPanel'; import type { DependencyAssessment } from '@/shared/services/api/types'; export function DependenciesPage() { @@ -121,12 +122,9 @@ export function DependenciesPage() { -
- - - - -
+
+ +
@@ -227,12 +225,3 @@ function Stat({ label, value }: { label: string; value: number | string }) {
); } - -function Identity({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} diff --git a/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx b/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx new file mode 100644 index 0000000..6043104 --- /dev/null +++ b/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useReview } from '@/features/review/hooks/useReview'; +import { useReviewStore } from '@/features/review/store'; +import { architectureService } from '@/shared/services/api/architecture'; +import type { EngineeringReview } from '@/shared/types/review'; +import type { RevisionManifestResponse } from '@/shared/services/api/types'; +import { EngineeringReviewPage } from './EngineeringReviewPage'; + +vi.mock('@/features/review/hooks/useReview', () => ({ + useReview: vi.fn(), +})); + +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { getRevisionManifest: vi.fn() }, +})); + +const review: EngineeringReview = { + schemaVersion: 'engineering-review.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + assessmentStatus: 'assessed', + categories: [], + findings: [], + summary: { + message: 'No findings were surfaced for this snapshot.', + findingsBySeverity: { info: 0, low: 0, medium: 0, high: 0, critical: 0 }, + assessedCategories: 8, + partiallyAssessedCategories: 0, + notAssessedCategories: 0, + insufficientEvidenceCategories: 0, + evidenceBackedFindingCount: 0, + fileScopedFindingCount: 0, + omittedUnsupportedDiagnosticCount: 0, + vulnerabilityScanning: 'not_assessed', + }, +}; + +const revisionManifest: RevisionManifestResponse = { + manifest: { + schemaVersion: 'revision-manifest.v1', + repositoryId: 'repo-1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + revisionRef: null, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + extractors: [{ name: 'typescript-extractor', version: '1.0.0' }], + producerSetHash: `sha256:${'1'.repeat(64)}`, + configHash: `sha256:${'2'.repeat(64)}`, + canonicalGraphHash: `sha256:${'3'.repeat(64)}`, + createdAt: '2026-07-25T00:00:00Z', + sealedAt: '2026-07-25T00:00:05Z', + }, + manifestDigest: `sha256:${'4'.repeat(64)}`, + verificationMethod: 'sha256-canonical-json', + verificationState: 'verified', + verificationNote: 'This digest is a SHA-256 over the canonical JSON encoding of the manifest fields above.', +}; + +function renderPage() { + return render( + + + , + ); +} + +describe('EngineeringReviewPage', () => { + beforeEach(() => { + vi.mocked(useReview).mockReturnValue({ + review, + data: review, + source: 'upload', + status: 'success', + loading: false, + error: null, + empty: false, + success: true, + retry: vi.fn(), + refresh: vi.fn(), + activeRepository: null, + completedRepositories: [], + emptyReason: null, + }); + useReviewStore.setState({ + review, + selectedFindingId: null, + filterCategory: 'all', + filterSeverity: 'all', + filterDiagnosticCode: null, + }); + vi.mocked(architectureService.getRevisionManifest).mockResolvedValue(revisionManifest); + }); + + it('keeps the sealed snapshot identity one click away instead of on the landing strip (#176)', async () => { + renderPage(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifestDigest)).not.toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifest.canonicalGraphHash!)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /details/i })); + + expect(screen.getByText('Manifest digest')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifestDigest)).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifest.canonicalGraphHash!)).toBeInTheDocument(); + }); + + it('still renders the evidence-backed summary and assessment matrix', () => { + renderPage(); + + expect(screen.getByText('Evidence-backed summary')).toBeInTheDocument(); + expect(screen.getByText('No findings were surfaced for this snapshot.')).toBeInTheDocument(); + expect( + screen.getByText('No overall score, grade, health percentage, or category score is produced.'), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/EngineeringReviewPage.tsx b/apps/frontend/src/app/pages/EngineeringReviewPage.tsx index 8836652..d20e9d9 100644 --- a/apps/frontend/src/app/pages/EngineeringReviewPage.tsx +++ b/apps/frontend/src/app/pages/EngineeringReviewPage.tsx @@ -10,6 +10,7 @@ import { FindingCard } from '@/features/review/components/FindingCard'; import { FindingDetail } from '@/features/review/components/FindingDetail'; import { ReviewFilters } from '@/features/review/components/ReviewFilters'; import { ExportMenu } from '@/shared/components/ui/ExportMenu'; +import { RevisionManifestPanel } from '@/features/architecture/components/RevisionManifestPanel'; import type { ReviewAssessmentState, ReviewSeverity } from '@/shared/types/review'; const STATE_LABEL: Record = { @@ -102,12 +103,9 @@ export function EngineeringReviewPage() { categories without sufficient evidence are marked Not assessed. -
- - - - -
+
+ +
@@ -207,15 +205,6 @@ export function EngineeringReviewPage() { ); } -function Identity({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - function StatusPanel({ text }: { text: string }) { return (
diff --git a/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx b/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx new file mode 100644 index 0000000..5e0d61f --- /dev/null +++ b/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import type { Repository } from '@/shared/types'; +import { RepositoryDetailPage } from './RepositoryDetailPage'; + +const repositoryState = vi.hoisted(() => ({ repositories: [] as Repository[] })); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: () => ({ repositories: repositoryState.repositories }), +})); + +vi.mock('@/features/repositories/components/RepositoryOutcomeSummary', () => ({ + RepositoryOutcomeSummary: ({ repository }: { repository: Repository }) => ( +
Outcome summary for {repository.name}
+ ), +})); + +const completedRepository: Repository = { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 10, + fileCount: 5, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-22T08:00:00Z', + meta: { + language: 'TypeScript', + framework: 'React', + totalFiles: 5, + totalFolders: 2, + entryPoint: 'src/main.tsx', + configFiles: [], + packageManager: 'npm', + hasReadme: true, + hasLicense: true, + licenseName: 'MIT', + }, + fileTree: [], +}; + +function renderPage(repositoryId = 'repo-1') { + return render( + + + } /> + + , + ); +} + +describe('RepositoryDetailPage', () => { + it('leads the completed repository landing view with the outcome summary, before the tabs (#176)', () => { + repositoryState.repositories = [completedRepository]; + + renderPage(); + + const summary = screen.getByTestId('outcome-summary-stub'); + expect(summary).toHaveTextContent('Outcome summary for sample'); + + const overviewTab = screen.getByRole('button', { name: 'Overview' }); + expect(summary.compareDocumentPosition(overviewTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('does not show the outcome summary for a repository that failed analysis', () => { + repositoryState.repositories = [ + { ...completedRepository, status: 'error', errorMessage: 'Extraction crashed.', meta: null }, + ]; + + renderPage(); + + expect(screen.queryByTestId('outcome-summary-stub')).not.toBeInTheDocument(); + expect(screen.getByText('Extraction crashed.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/RepositoryDetailPage.tsx b/apps/frontend/src/app/pages/RepositoryDetailPage.tsx index f7e6fc3..a7c3183 100644 --- a/apps/frontend/src/app/pages/RepositoryDetailPage.tsx +++ b/apps/frontend/src/app/pages/RepositoryDetailPage.tsx @@ -22,6 +22,7 @@ import { EmptyState } from '@/shared/components/ui/EmptyState'; import { Badge } from '@/shared/components/ui/Badge'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { RepositoryExplorer } from '@/features/explorer/components/RepositoryExplorer'; +import { RepositoryOutcomeSummary } from '@/features/repositories/components/RepositoryOutcomeSummary'; import { useRepositoryDetail } from '@/features/repositories/hooks/useRepositoryDetail'; import { useRepositoryTree } from '@/features/repositories/hooks/useRepositoryTree'; import { repositoryStatusVariant } from '@/features/repositories/status'; @@ -85,6 +86,8 @@ export function RepositoryDetailPage() {
) : repo.status === 'completed' && repo.meta ? ( <> + +
{tabs.map((tab) => ( +
+ +
+ + + +
+ +
+ +
+

Headline

+

+ {summary.headline.state === 'loading' + ? 'Loading...' + : summary.headline.state === 'assessed' + ? summary.headline.message + : 'Not assessed'} +

+
+
+ + {summary.coverage.state === 'assessed' && ( +

+ Assessed via {summary.coverage.extractorCount} extractor{summary.coverage.extractorCount === 1 ? '' : 's'} across{' '} + {summary.coverage.languageCount} detected language{summary.coverage.languageCount === 1 ? '' : 's'}. +

+ )} + + {verifyOpen && ( +
+ +
+ )} +
+ ); +} + +function SummaryTile({ + icon: Icon, + label, + state, + value, + detail, +}: { + icon: LucideIcon; + label: string; + state: OutcomeAssessmentState; + value: string | null; + detail?: string | null; +}) { + return ( +
+
+ + {label} +
+

+ {state === 'loading' ? 'Loading...' : state === 'assessed' ? value : 'Not assessed'} +

+ {state === 'assessed' && detail &&

{detail}

} +
+ ); +} diff --git a/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.test.ts b/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.test.ts new file mode 100644 index 0000000..fb953c8 --- /dev/null +++ b/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.test.ts @@ -0,0 +1,215 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { backendService } from '@/shared/services/backend'; +import { ApiError } from '@/shared/services/api'; +import type { Repository } from '@/shared/types'; +import type { ArchitectureModel } from '@/shared/types/architecture'; +import type { EngineeringReview } from '@/shared/types/review'; +import type { RepositoryInsights } from '@/shared/types/insights'; +import type { DependencyGraphResponse } from '@/shared/services/api/types'; +import { useRepositoryOutcomeSummary } from './useRepositoryOutcomeSummary'; + +const repository: Repository = { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 10, + fileCount: 5, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-22T08:00:00Z', + meta: { + language: 'TypeScript', + framework: 'React', + totalFiles: 5, + totalFolders: 2, + entryPoint: 'src/main.tsx', + configFiles: [], + packageManager: 'npm', + hasReadme: true, + hasLicense: true, + licenseName: 'MIT', + }, + fileTree: [], +}; + +const architecture: ArchitectureModel = { + repositoryId: 'repo-1', + repositoryName: 'sample', + architectureType: 'modular-monolith', + detectedLayers: [], + nodes: [], + edges: [], + modules: [], + requestFlow: [], + diagnostics: [], + summary: { + language: 'TypeScript', + framework: 'React', + totalModules: 12, + totalNodes: 48, + entryPoint: 'src/main.tsx', + architecturePattern: 'Modular monolith', + }, +}; + +const review: EngineeringReview = { + schemaVersion: 'engineering-review.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + assessmentStatus: 'assessed', + categories: [], + findings: [], + summary: { + message: 'summary', + findingsBySeverity: { info: 1, low: 0, medium: 0, high: 2, critical: 0 }, + assessedCategories: 8, + partiallyAssessedCategories: 0, + notAssessedCategories: 0, + insufficientEvidenceCategories: 0, + evidenceBackedFindingCount: 3, + fileScopedFindingCount: 0, + omittedUnsupportedDiagnosticCount: 0, + vulnerabilityScanning: 'not_assessed', + }, +}; + +const dependencyGraph: DependencyGraphResponse = { + schemaVersion: 'dependency-graph.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + nodes: [], + edges: [], + totalDependencies: 27, + manifestCount: 1, + diagnostics: [], + vulnerabilityAssessment: { status: 'not_computed' }, + outdatedAssessment: { status: 'not_computed' }, +}; + +const insights: RepositoryInsights = { + schemaVersion: 'repository-insights.v1', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + computedAt: '2026-07-25T00:00:00Z', + snapshotCreatedAt: '2026-07-25T00:00:00Z', + snapshotSealedAt: '2026-07-25T00:00:00Z', + extractorSet: [ + { name: 'typescript-extractor', version: '1.0.0', evidenceRecordCount: 10 }, + { name: 'dependency-manifest', version: '1.2.0', evidenceRecordCount: 3 }, + ], + metrics: [], + relationshipsByPredicate: [], + diagnosticsBySeverity: [], + diagnosticsByCode: [], + languages: [{ key: 'typescript', label: 'TypeScript', value: 5 }], + changeOverTime: { assessmentState: 'not_assessed', message: 'not tracked' }, +}; + +describe('useRepositoryOutcomeSummary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('composes every section from the real Architecture/Review/Dependencies/Insights responses', async () => { + vi.spyOn(backendService, 'fetchArchitecture').mockResolvedValue(architecture); + vi.spyOn(backendService, 'fetchReview').mockResolvedValue(review); + vi.spyOn(backendService, 'fetchDependencyGraph').mockResolvedValue(dependencyGraph); + vi.spyOn(backendService, 'fetchInsights').mockResolvedValue(insights); + + const { result } = renderHook(() => useRepositoryOutcomeSummary(repository)); + + await waitFor(() => expect(result.current.stack.state).toBe('assessed')); + + expect(result.current.stack).toEqual({ + state: 'assessed', + language: 'TypeScript', + framework: 'React', + architecturePattern: 'Modular monolith', + }); + expect(result.current.structure).toEqual({ state: 'assessed', totalModules: 12, totalNodes: 48 }); + expect(result.current.dependencies).toEqual({ state: 'assessed', totalDependencies: 27 }); + // The highest-severity non-zero bucket wins -- 2 high findings outrank the 1 info finding. + expect(result.current.headline).toEqual({ state: 'assessed', message: '2 high-severity findings', severity: 'high' }); + expect(result.current.coverage).toEqual({ state: 'assessed', extractorCount: 2, languageCount: 1 }); + }); + + it('reports no findings honestly rather than inventing a clean bill of health', async () => { + vi.spyOn(backendService, 'fetchArchitecture').mockResolvedValue(architecture); + vi.spyOn(backendService, 'fetchReview').mockResolvedValue({ + ...review, + summary: { ...review.summary, findingsBySeverity: { info: 0, low: 0, medium: 0, high: 0, critical: 0 } }, + }); + vi.spyOn(backendService, 'fetchDependencyGraph').mockResolvedValue(dependencyGraph); + vi.spyOn(backendService, 'fetchInsights').mockResolvedValue(insights); + + const { result } = renderHook(() => useRepositoryOutcomeSummary(repository)); + + await waitFor(() => expect(result.current.headline.state).toBe('assessed')); + expect(result.current.headline.message).toBe('No evidence-backed findings were surfaced'); + expect(result.current.headline.severity).toBeNull(); + }); + + it('marks a section not_assessed, never fabricated, when its snapshot API 404s', async () => { + vi.spyOn(backendService, 'fetchArchitecture').mockResolvedValue(architecture); + vi.spyOn(backendService, 'fetchReview').mockRejectedValue(new ApiError(404, 'Not Found', null, '/analysis/repo-1/review')); + vi.spyOn(backendService, 'fetchDependencyGraph').mockRejectedValue(new ApiError(404, 'Not Found', null, '/analysis/repo-1/review')); + vi.spyOn(backendService, 'fetchInsights').mockRejectedValue(new ApiError(404, 'Not Found', null, '/analysis/repo-1/review')); + + const { result } = renderHook(() => useRepositoryOutcomeSummary(repository)); + + await waitFor(() => expect(result.current.stack.state).toBe('assessed')); + expect(result.current.headline).toEqual({ state: 'not_assessed', message: null, severity: null }); + expect(result.current.dependencies).toEqual({ state: 'not_assessed', totalDependencies: null }); + expect(result.current.coverage).toEqual({ state: 'not_assessed', extractorCount: null, languageCount: null }); + }); + + it('stays in the loading state and fetches nothing while the repository has not completed analysis', () => { + const fetchArchitecture = vi.spyOn(backendService, 'fetchArchitecture'); + const { result } = renderHook(() => + useRepositoryOutcomeSummary({ ...repository, status: 'analysing' }), + ); + + expect(result.current.stack.state).toBe('loading'); + expect(fetchArchitecture).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.ts b/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.ts new file mode 100644 index 0000000..a425b6c --- /dev/null +++ b/apps/frontend/src/features/repositories/hooks/useRepositoryOutcomeSummary.ts @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import type { Repository } from '@/shared/types'; +import type { EngineeringReview, ReviewSeverity } from '@/shared/types/review'; +import { backendService } from '@/shared/services/backend'; + +export type OutcomeAssessmentState = 'loading' | 'assessed' | 'not_assessed'; + +export interface RepositoryOutcomeSummary { + stack: { + state: OutcomeAssessmentState; + language: string | null; + framework: string | null; + architecturePattern: string | null; + }; + structure: { + state: OutcomeAssessmentState; + totalModules: number | null; + totalNodes: number | null; + }; + dependencies: { + state: OutcomeAssessmentState; + totalDependencies: number | null; + }; + headline: { + state: OutcomeAssessmentState; + message: string | null; + severity: ReviewSeverity | null; + }; + coverage: { + state: OutcomeAssessmentState; + extractorCount: number | null; + languageCount: number | null; + }; +} + +const LOADING_SUMMARY: RepositoryOutcomeSummary = { + stack: { state: 'loading', language: null, framework: null, architecturePattern: null }, + structure: { state: 'loading', totalModules: null, totalNodes: null }, + dependencies: { state: 'loading', totalDependencies: null }, + headline: { state: 'loading', message: null, severity: null }, + coverage: { state: 'loading', extractorCount: null, languageCount: null }, +}; + +const SEVERITY_ORDER: ReviewSeverity[] = ['critical', 'high', 'medium', 'low', 'info']; + +function headlineFromReview(review: EngineeringReview): { message: string; severity: ReviewSeverity | null } { + for (const severity of SEVERITY_ORDER) { + const count = review.summary.findingsBySeverity[severity]; + if (count > 0) { + return { message: `${count} ${severity}-severity finding${count === 1 ? '' : 's'}`, severity }; + } + } + return { message: 'No evidence-backed findings were surfaced', severity: null }; +} + +/** + * Composes the repository landing summary purely from existing sealed-snapshot + * read APIs (Architecture, Review, Dependencies, Insights) -- no LLM, no + * client-side computation of anything the backend doesn't already expose. + * Each section fails independently to `not_assessed`: a repository can be + * `completed` without a sealed ri.v1 snapshot yet (the same 404 contract + * Architecture/Review/Insights/Dependencies already surface individually), and + * this is a lightweight preview, not a substitute for those pages' own + * detailed error/retry states. + */ +export function useRepositoryOutcomeSummary(repository: Repository | null): RepositoryOutcomeSummary { + const [summary, setSummary] = useState(LOADING_SUMMARY); + const repositoryId = repository?.status === 'completed' ? repository.id : null; + + useEffect(() => { + if (!repository || repositoryId === null) { + setSummary(LOADING_SUMMARY); + return; + } + + let cancelled = false; + setSummary(LOADING_SUMMARY); + + async function load() { + const [architectureResult, reviewResult, dependenciesResult, insightsResult] = await Promise.allSettled([ + backendService.fetchArchitecture(repository as Repository), + backendService.fetchReview(repository as Repository), + backendService.fetchDependencyGraph(repositoryId as string), + backendService.fetchInsights(repository as Repository), + ]); + if (cancelled) return; + + const architecture = architectureResult.status === 'fulfilled' ? architectureResult.value : null; + const review = reviewResult.status === 'fulfilled' ? reviewResult.value : null; + const dependencies = dependenciesResult.status === 'fulfilled' ? dependenciesResult.value : null; + const insights = insightsResult.status === 'fulfilled' ? insightsResult.value : null; + const headline = review ? headlineFromReview(review) : null; + + setSummary({ + stack: architecture + ? { + state: 'assessed', + language: architecture.summary.language, + framework: architecture.summary.framework, + architecturePattern: architecture.summary.architecturePattern, + } + : { state: 'not_assessed', language: null, framework: null, architecturePattern: null }, + structure: architecture + ? { + state: 'assessed', + totalModules: architecture.summary.totalModules, + totalNodes: architecture.summary.totalNodes, + } + : { state: 'not_assessed', totalModules: null, totalNodes: null }, + dependencies: dependencies + ? { state: 'assessed', totalDependencies: dependencies.totalDependencies } + : { state: 'not_assessed', totalDependencies: null }, + headline: headline + ? { state: 'assessed', message: headline.message, severity: headline.severity } + : { state: 'not_assessed', message: null, severity: null }, + coverage: insights + ? { + state: 'assessed', + extractorCount: insights.extractorSet.length, + languageCount: insights.languages.length, + } + : { state: 'not_assessed', extractorCount: null, languageCount: null }, + }); + } + + void load(); + return () => { + cancelled = true; + }; + }, [repository, repositoryId]); + + return summary; +} diff --git a/apps/frontend/src/shared/components/layout/Sidebar.test.tsx b/apps/frontend/src/shared/components/layout/Sidebar.test.tsx index 9536d43..a1b3ef0 100644 --- a/apps/frontend/src/shared/components/layout/Sidebar.test.tsx +++ b/apps/frontend/src/shared/components/layout/Sidebar.test.tsx @@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { useAppStore } from '@/app/store/useAppStore'; import { useAuthStore } from '@/app/store/useAuthStore'; +import { primaryNavigationSurfaces } from '@/app/routes/productSurfaces'; import { Sidebar } from './Sidebar'; const initialAppState = useAppStore.getState(); @@ -79,6 +80,34 @@ describe('Sidebar', () => { expect(architecture).toHaveAttribute('href', '/architecture'); }); + it('groups secondary surfaces under "More" with reduced weight, without removing them (#176)', () => { + renderSidebar(); + + const flagship = primaryNavigationSurfaces.filter((item) => item.navGroup === 'flagship'); + const secondary = primaryNavigationSurfaces.filter((item) => item.navGroup === 'secondary'); + expect(flagship.map((item) => item.label)).toEqual(['Dashboard', 'Repositories', 'Upload Repository', 'Architecture']); + expect(secondary.length).toBeGreaterThan(0); + + const moreLabel = screen.getByText('More'); + expect(moreLabel).toBeInTheDocument(); + + // Every secondary surface is still a real, reachable, focusable link -- + // "reduce emphasis" never means "hide" or "remove". + for (const item of secondary) { + const link = screen.getByRole('link', { name: item.label }); + expect(link).toHaveAttribute('href', item.path); + } + + // The "More" heading sits after every flagship link and before every + // secondary one, and secondary links read with a lighter font weight. + const dashboardLink = screen.getByRole('link', { name: 'Dashboard' }); + const firstSecondaryLink = screen.getByRole('link', { name: secondary[0].label }); + expect(dashboardLink.compareDocumentPosition(moreLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(moreLabel.compareDocumentPosition(firstSecondaryLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(firstSecondaryLink.className).toContain('font-normal'); + expect(dashboardLink.className).toContain('font-medium'); + }); + it('marks only the active route with aria-current', () => { render( diff --git a/apps/frontend/src/shared/components/layout/Sidebar.tsx b/apps/frontend/src/shared/components/layout/Sidebar.tsx index 2c5f73d..a9e19bc 100644 --- a/apps/frontend/src/shared/components/layout/Sidebar.tsx +++ b/apps/frontend/src/shared/components/layout/Sidebar.tsx @@ -5,7 +5,60 @@ import { ChevronLeft, Hexagon } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { useAppStore } from '@/app/store/useAppStore'; import { useAuthStore } from '@/app/store/useAuthStore'; -import { primaryNavigationSurfaces } from '@/app/routes/productSurfaces'; +import { primaryNavigationSurfaces, type NavigableProductSurface } from '@/app/routes/productSurfaces'; + +const flagshipNavigationSurfaces = primaryNavigationSurfaces.filter((item) => item.navGroup === 'flagship'); +const secondaryNavigationSurfaces = primaryNavigationSurfaces.filter((item) => item.navGroup === 'secondary'); + +/** One sidebar row. `muted` reduces (never removes) a secondary surface's visual weight (#176). */ +function NavLink({ + item, + isActive, + isMobile, + sidebarCollapsed, + onNavigate, + muted = false, +}: { + item: NavigableProductSurface; + isActive: boolean; + isMobile: boolean; + sidebarCollapsed: boolean; + onNavigate: () => void; + muted?: boolean; +}) { + return ( + { + if (isMobile) onNavigate(); + }} + aria-label={item.label} + aria-current={isActive ? 'page' : undefined} + className={cn( + 'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors', + muted ? 'font-normal' : 'font-medium', + isActive + ? 'bg-sidebar-accent text-sidebar-accent-foreground' + : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground' + )} + > + + + {(isMobile || !sidebarCollapsed) && ( + + {item.label} + + )} + + + ); +} export function Sidebar() { const location = useLocation(); @@ -133,41 +186,45 @@ export function Sidebar() {