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 apps/frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ interface KnownFinding {
const SHELL_FINDINGS: KnownFinding[] = [
{ issue: 236, rule: 'button-name', testId: 'notification-menu-trigger', maxCount: 1 },
{ issue: 236, rule: 'button-name', testId: 'user-menu-trigger', maxCount: 1 },
{ issue: 238, rule: 'color-contrast', testId: 'secondary-navigation-label', maxCount: 1 },
// #238's `secondary-navigation-label` allowance is intentionally gone: the
// "More" label it covered was replaced by the AA-contrast "Analysis" and
// "Assist" labels (#289), so the allowance matched nothing and would only
// have masked a future regression on an element that no longer exists.
];

function byLabel(label: string) {
Expand Down
23 changes: 16 additions & 7 deletions apps/frontend/src/app/pages/AIWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import { cn } from '@/shared/utils/cn';
const AI_WORKSPACE_LIMITATION =
'Free-form questions require a configured AI provider. They receive sealed-snapshot structural facts, heuristic roles, and observed paths, but no source-file contents; provider answers therefore have no automatic citations.';

// States what the surface actually does rather than generic "chat" framing:
// answers are grounded in the already-computed sealed snapshot, and no
// source-file contents leave the instance. It must not claim the thread is
// forgotten -- conversation turns are persisted per owner and repository and
// restored on return (#231), and recent turns are replayed as context, so
// promising otherwise would be a false privacy assurance.
const AI_WORKSPACE_SUBTITLE =
'Answers are grounded in the structural facts your last analysis already computed — no source-file contents are sent. Your thread is saved for this repository and restored when you return.';

export function AIWorkspacePage() {
const navigate = useNavigate();
const aiWorkspace = useAIWorkspace();
Expand All @@ -22,12 +31,12 @@ export function AIWorkspacePage() {
if (aiWorkspace.emptyReason === 'no-completed-repositories') {
return (
<div>
<PageHeader title="AI Workspace" description="Ask questions about your codebase using AI" />
<PageHeader title="AI Workspace" description={AI_WORKSPACE_SUBTITLE} />
<PreviewBanner limitation={AI_WORKSPACE_LIMITATION} />
<EmptyState
icon={Bot}
title="No analysed repositories"
description="Upload and analyse a repository first. AI-powered explanations require a completed analysis pipeline."
description="Upload and analyse a repository first. Structural facts for this workspace come from a completed analysis pipeline."
action={{ label: 'Upload Repository', onClick: () => navigate('/upload') }}
/>
</div>
Expand All @@ -37,20 +46,20 @@ export function AIWorkspacePage() {
if (aiWorkspace.emptyReason === 'no-active-repository' || !activeRepository) {
return (
<div>
<PageHeader title="AI Workspace" description="Ask questions about your codebase using AI" />
<PageHeader title="AI Workspace" description={AI_WORKSPACE_SUBTITLE} />
<PreviewBanner limitation={AI_WORKSPACE_LIMITATION} />
<EmptyState
icon={Bot}
title="Select a repository"
description="Choose an analysed repository from the top bar to start asking questions."
description="Choose an analysed repository from the top bar to see its computed structural facts."
/>
</div>
);
}

return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[460px] min-w-0 flex-col">
<PageHeader title="AI Workspace" description={`AI-powered exploration of ${activeRepository.name}`}>
<PageHeader title="AI Workspace" description={`${activeRepository.name} · ${AI_WORKSPACE_SUBTITLE}`}>
<DataSourceBadge source={aiWorkspace.source} />
</PageHeader>
<PreviewBanner limitation={AI_WORKSPACE_LIMITATION} />
Expand All @@ -63,9 +72,9 @@ export function AIWorkspacePage() {
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted mx-auto mb-4">
<Bot className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground mb-1">Ask about {activeRepository.name}</p>
<p className="text-sm font-medium text-foreground mb-1">Structural facts for {activeRepository.name}</p>
<p className="text-xs text-muted-foreground">
Questions are sent to your configured provider with structural facts from the sealed snapshot. Source-file contents are not sent.
Each answer is generated from the sealed structural facts your last analysis already computed for this repository. No source-file contents are sent to the provider. Your conversation is saved for this repository and restored when you come back, and recent turns are sent along as context.
</p>
</div>
</div>
Expand Down
134 changes: 134 additions & 0 deletions apps/frontend/src/app/pages/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import type { Repository } from '@/shared/types';
import { DashboardPage } from './DashboardPage';

const mockDashboard = vi.hoisted(() => vi.fn());
vi.mock('@/features/repositories/hooks/useRepositoryDashboard', () => ({
useRepositoryDashboard: () => mockDashboard(),
}));

function makeRepository(overrides: Partial<Repository>): Repository {
return {
id: 'repo-1',
name: 'partha',
source: 'github',
size: 1024,
fileCount: 42,
status: 'completed',
analysisStage: null,
analysisProgress: 100,
uploadedAt: '2026-08-01T00:00:00Z',
meta: null,
fileTree: [],
...overrides,
};
}

function renderDashboard() {
return render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
);
}

describe('DashboardPage', () => {
it('surfaces the most recently completed analysis with its real revision identity', () => {
const older = makeRepository({
id: 'repo-older',
name: 'older-repo',
analysedAt: '2026-08-01T10:00:00Z',
revision: { kind: 'git', ref: 'refs/heads/main', value: '1234567890abcdef1234567890abcdef12345678' },
});
const newer = makeRepository({
id: 'repo-newer',
name: 'newest-repo',
analysedAt: '2026-08-09T15:30:00Z',
revision: { kind: 'git', ref: 'refs/heads/main', value: 'abcdef1234567890abcdef1234567890abcdef12' },
});
const middle = makeRepository({
id: 'repo-middle',
name: 'middle-repo',
analysedAt: '2026-08-05T09:00:00Z',
revision: { kind: 'git', ref: 'refs/heads/main', value: 'fedcba0987654321fedcba0987654321fedcba09' },
});
// Deliberately unsorted, with the winner neither first nor last: picking
// either end of the list must not accidentally pass this test.
mockDashboard.mockReturnValue({
repositories: [older, newer, middle],
metrics: { totalRepositories: 3, completedRepositories: 3, totalFiles: 126, totalSize: 3072 },
selectRepository: vi.fn(),
});

renderDashboard();

const summary = screen.getByTestId('latest-analysis-summary');
expect(within(summary).getByText('newest-repo')).toBeInTheDocument();
expect(within(summary).getByText('git abcdef1')).toBeInTheDocument();
expect(within(summary).queryByText('older-repo')).not.toBeInTheDocument();
expect(within(summary).queryByText('middle-repo')).not.toBeInTheDocument();
});

it('labels a git revision with its kind and keeps the full immutable value available', () => {
const repo = makeRepository({
analysedAt: '2026-08-09T15:30:00Z',
revision: { kind: 'git', ref: 'refs/heads/main', value: 'abcdef1234567890abcdef1234567890abcdef12' },
});
mockDashboard.mockReturnValue({
repositories: [repo],
metrics: { totalRepositories: 1, completedRepositories: 1, totalFiles: 42, totalSize: 1024 },
selectRepository: vi.fn(),
});

renderDashboard();

// The abbreviation is display-only -- the exact revision identity stays
// recoverable, never replaced by its 7-character prefix.
const revision = within(screen.getByTestId('latest-analysis-summary')).getByText('git abcdef1');
expect(revision).toHaveAttribute('title', 'abcdef1234567890abcdef1234567890abcdef12');
});

it('renders an upload revision as a short content hash, not the raw sha256 value', () => {
const repo = makeRepository({
analysedAt: '2026-08-09T15:30:00Z',
revision: { kind: 'upload', value: 'sha256:deadbeefcafefeed1234567890abcdef1234567890abcdef1234567890abcd' },
});
mockDashboard.mockReturnValue({
repositories: [repo],
metrics: { totalRepositories: 1, completedRepositories: 1, totalFiles: 42, totalSize: 1024 },
selectRepository: vi.fn(),
});

renderDashboard();

expect(within(screen.getByTestId('latest-analysis-summary')).getByText('upload deadbee')).toBeInTheDocument();
});

it('omits the summary line when no repository has a completed analysis yet', () => {
const repo = makeRepository({ status: 'analysing', analysedAt: undefined, revision: null });
mockDashboard.mockReturnValue({
repositories: [repo],
metrics: { totalRepositories: 1, completedRepositories: 0, totalFiles: 42, totalSize: 1024 },
selectRepository: vi.fn(),
});

renderDashboard();

expect(screen.queryByText(/Most recently analysed:/)).not.toBeInTheDocument();
});

it('still shows the empty state, without the summary line, when there are no repositories at all', () => {
mockDashboard.mockReturnValue({
repositories: [],
metrics: { totalRepositories: 0, completedRepositories: 0, totalFiles: 0, totalSize: 0 },
selectRepository: vi.fn(),
});

renderDashboard();

expect(screen.getByText('Welcome to PARTHA')).toBeInTheDocument();
expect(screen.queryByText(/Most recently analysed:/)).not.toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions apps/frontend/src/app/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { LayoutDashboard, FolderGit2, Upload, Activity, Clock, Github } from 'lucide-react';
Expand All @@ -9,11 +10,38 @@ import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge';
import { useRepositoryDashboard } from '@/features/repositories/hooks/useRepositoryDashboard';
import { repositoryStatusVariant } from '@/features/repositories/status';
import { formatFileSize } from '@/shared/utils/cn';
import type { Repository, RepositoryRevision } from '@/shared/types';

/**
* Abbreviates the real revision identity already on the repository record
* (#87, RFC-0001 §3) -- never invented. Both kinds render as `kind value`, the
* same shape Insights and Engineering Review use, so a bare hex string is
* never shown without saying what it is. The abbreviation is display-only: the
* full immutable value is kept in the `title` at the call site, since a
* 7-character prefix is not itself an identity.
*/
function shortRevisionLabel(revision: RepositoryRevision): string {
if (revision.kind === 'git') return `git ${revision.value.slice(0, 7)}`;
return `upload ${revision.value.replace(/^sha256:/, '').slice(0, 7)}`;
}

export function DashboardPage() {
const navigate = useNavigate();
const { repositories, metrics, selectRepository } = useRepositoryDashboard();

// Most useful single fact: which repository's analysis is most current, as
// of what revision -- derived only from fields the repository list (this
// page's existing data) already carries, never a separate fetch.
const mostRecentlyAnalysed = useMemo<(Repository & { analysedAt: string }) | null>(() => {
const analysed = repositories.filter(
(repo): repo is Repository & { analysedAt: string } => repo.status === 'completed' && Boolean(repo.analysedAt),
);
if (analysed.length === 0) return null;
return analysed.reduce((latest, repo) =>
new Date(repo.analysedAt).getTime() > new Date(latest.analysedAt).getTime() ? repo : latest,
);
}, [repositories]);

if (repositories.length === 0) {
return (
<div>
Expand All @@ -40,6 +68,23 @@ export function DashboardPage() {
</button>
</PageHeader>

{mostRecentlyAnalysed && (
<p data-testid="latest-analysis-summary" className="mb-6 text-sm text-muted-foreground">
Most recently analysed:{' '}
<span className="font-medium text-foreground">{mostRecentlyAnalysed.name}</span>
{' — '}
{new Date(mostRecentlyAnalysed.analysedAt).toLocaleString()}
{mostRecentlyAnalysed.revision && (
<>
{' at revision '}
<code className="text-xs text-foreground" title={mostRecentlyAnalysed.revision.value}>
{shortRevisionLabel(mostRecentlyAnalysed.revision)}
</code>
</>
)}
</p>
)}

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<MetricCard label="Repositories" value={metrics.totalRepositories} icon={FolderGit2} />
<MetricCard label="Analysed" value={metrics.completedRepositories} icon={Activity} />
Expand Down
Loading
Loading