diff --git a/apps/azure/src/components/EvidenceSheet.tsx b/apps/azure/src/components/EvidenceSheet.tsx
new file mode 100644
index 000000000..d1a1db354
--- /dev/null
+++ b/apps/azure/src/components/EvidenceSheet.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { X } from 'lucide-react';
+import type { Finding, ProcessStateItem } from '@variscout/core';
+
+export interface EvidenceSheetProps {
+ /** When null, sheet is hidden. */
+ item: ProcessStateItem | null;
+ /** Null while loading; empty array when no findings. */
+ findings: readonly Finding[] | null;
+ onSelectFinding: (finding: Finding) => void;
+ onClose: () => void;
+}
+
+const STATUS_LABELS: Record = {
+ observed: 'Observed',
+ investigating: 'Investigating',
+ analyzed: 'Analyzed',
+ improving: 'Improving',
+ resolved: 'Resolved',
+};
+
+const EvidenceSheet: React.FC = ({
+ item,
+ findings,
+ onSelectFinding,
+ onClose,
+}) => {
+ React.useEffect(() => {
+ if (!item) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [item, onClose]);
+
+ if (!item) return null;
+
+ return (
+ <>
+
+
+
+
{item.label}
+
+
+
+ {findings === null ? (
+
Loading findings…
+ ) : findings.length === 0 ? (
+
+ No findings recorded for this item yet.
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default EvidenceSheet;
diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx
index ff728744b..217cdc12c 100644
--- a/apps/azure/src/components/ProcessHubReviewPanel.tsx
+++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx
@@ -17,7 +17,6 @@ import { ProcessHubCurrentStatePanel } from '@variscout/ui';
import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions';
import ProcessHubCadenceQueues from './ProcessHubCadenceQueues';
import { formatLatestActivity } from './ProcessHubFormat';
-import { safeTrackEvent } from '../lib/appInsights';
interface ProcessHubReviewPanelProps {
rollup: ProcessHubRollup;
@@ -32,6 +31,11 @@ interface ProcessHubReviewPanelProps {
onRequestEditNote: (item: ProcessStateItem, note: ProcessStateNote, hubId: string) => void;
onDeleteNote: (item: ProcessStateItem, noteId: string, hubId: string) => void;
currentUserId: string;
+ /** Evidence wiring (PR #2) — async finding loader; Dashboard owns sheet state. */
+ loadFindingsForItem: (item: ProcessStateItem, hubId: string) => Promise;
+ /** count is threaded through so Dashboard can include it in chip-click telemetry. */
+ onChipClick: (item: ProcessStateItem, hubId: string, count: number) => void;
+ onFindingSelect: (item: ProcessStateItem, finding: Finding, hubId: string) => void;
}
const SnapshotCard: React.FC<{
@@ -65,6 +69,9 @@ const ProcessHubReviewPanel: React.FC = ({
onRequestEditNote,
onDeleteNote,
currentUserId,
+ loadFindingsForItem,
+ onChipClick,
+ onFindingSelect,
}) => {
const cadence = buildProcessHubCadence(rollup);
const currentState = buildCurrentProcessState(rollup, cadence);
@@ -104,34 +111,18 @@ const ProcessHubReviewPanel: React.FC = ({
[rollup.investigations]
);
- // findingsFor: chip count is derived from
- // ProcessHubInvestigationMetadata.findingCounts (already on the rollup —
- // no extra data load needed). Returns an array of synthetic Finding-shaped
- // placeholder objects with just enough surface (id) for the chip to count.
- //
- // Real Finding[] objects aren't loaded hub-wide on Dashboard today. The
- // EvidenceSheet rendering (which would need the full objects) is deferred
- // to a follow-up PR — see plan PR #5 future work.
- const findingsFor = React.useCallback(
- (item: ProcessStateItem): readonly Finding[] => {
+ // countFor: cheap derivation from rollup metadata (same arithmetic as the
+ // PR #99 synthetic-Finding length, but returns the integer directly).
+ const countFor = React.useCallback(
+ (item: ProcessStateItem): number => {
const investigationIds = investigationIdResolver(item);
- let totalRelevantCount = 0;
+ let total = 0;
for (const invId of investigationIds) {
const inv = rollup.investigations.find(i => i.id === invId);
const counts = inv?.metadata?.findingCounts ?? {};
- totalRelevantCount +=
- (counts.analyzed ?? 0) + (counts.improving ?? 0) + (counts.resolved ?? 0);
+ total += (counts.analyzed ?? 0) + (counts.improving ?? 0) + (counts.resolved ?? 0);
}
- if (totalRelevantCount === 0) return [];
- // Placeholder objects carry only `id` — chip rendering only reads `.length`.
- // The double cast is the documented pragmatic shortcut for this PR (see
- // spec § Implementation Reality Notes). The follow-up EvidenceSheet PR
- // will introduce a narrow `FindingCountPlaceholder = Pick`
- // type at the panel's evidence-contract boundary so the cast goes away.
- // TODO(evidence-sheet-pr): remove this cast once contracts narrow.
- return Array.from({ length: totalRelevantCount }, (_, idx) => ({
- id: `${item.id}-finding-placeholder-${idx}`,
- })) as unknown as readonly Finding[];
+ return total;
},
[rollup.investigations, investigationIdResolver]
);
@@ -159,26 +150,6 @@ const ProcessHubReviewPanel: React.FC = ({
[rollup.investigations]
);
- const handleChipClick = React.useCallback(
- (item: ProcessStateItem, findings: readonly Finding[]) => {
- safeTrackEvent('process_hub.evidence_chip_click', {
- hubId: rollup.hub.id,
- responsePath: item.responsePath,
- lens: item.lens,
- evidenceCount: findings.length,
- });
- // Navigate to a linked investigation. Per-investigation items use
- // item.investigationIds[0] (the order the projection builder produced —
- // typically a single linked investigation, so most-recency is moot).
- // Hub-aggregate items fall back to defaultInvestigationId, which is
- // sorted by `modified` descending. Full sheet rendering deferred to a
- // follow-up PR — when Dashboard loads findings hub-wide.
- const targetId = item.investigationIds?.[0] ?? defaultInvestigationId;
- if (targetId) onOpenInvestigation(targetId);
- },
- [rollup.hub.id, defaultInvestigationId, onOpenInvestigation]
- );
-
const headingId = `process-hub-current-state-${rollup.hub.id}`;
return (
@@ -215,7 +186,12 @@ const ProcessHubReviewPanel: React.FC = ({
actionFor,
onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id),
}}
- evidence={{ findingsFor, onChipClick: handleChipClick }}
+ evidence={{
+ countFor,
+ loadFindingsFor: item => loadFindingsForItem(item, rollup.hub.id),
+ onChipClick: item => onChipClick(item, rollup.hub.id, countFor(item)),
+ onFindingSelect: (item, finding) => onFindingSelect(item, finding, rollup.hub.id),
+ }}
notes={{
notesFor,
onRequestAddNote: item => onRequestAddNote(item, rollup.hub.id),
diff --git a/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx b/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx
new file mode 100644
index 000000000..7b988b7f2
--- /dev/null
+++ b/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { Finding, ProcessStateItem } from '@variscout/core';
+import EvidenceSheet from '../EvidenceSheet';
+
+const buildItem = (overrides: Partial = {}): ProcessStateItem => ({
+ id: 'item-1',
+ lens: 'outcome',
+ severity: 'amber',
+ responsePath: 'monitor',
+ source: 'review-signal',
+ label: 'Capability gap',
+ ...overrides,
+});
+
+const buildFinding = (id: string, status: Finding['status'], text = 'A finding'): Finding =>
+ ({
+ id,
+ text,
+ createdAt: 1714000000000,
+ context: {} as Finding['context'],
+ status,
+ comments: [],
+ statusChangedAt: 1714000000000,
+ }) as Finding;
+
+describe('EvidenceSheet', () => {
+ it('renders nothing when item is null', () => {
+ render();
+ expect(screen.queryByTestId('evidence-sheet')).not.toBeInTheDocument();
+ });
+
+ it('shows a loading state when findings is null', () => {
+ render(
+
+ );
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it('shows empty-state placeholder when findings is empty', () => {
+ render(
+
+ );
+ expect(screen.getByText(/no findings recorded/i)).toBeInTheDocument();
+ });
+
+ it('renders finding labels + statuses', () => {
+ const findings = [
+ buildFinding('f-1', 'analyzed', 'First'),
+ buildFinding('f-2', 'resolved', 'Second'),
+ ];
+ render(
+
+ );
+ expect(screen.getByText('First')).toBeInTheDocument();
+ expect(screen.getByText('Second')).toBeInTheDocument();
+ expect(screen.getByText(/analyzed/i)).toBeInTheDocument();
+ expect(screen.getByText(/resolved/i)).toBeInTheDocument();
+ });
+
+ it('fires onSelectFinding when a finding row is clicked', () => {
+ const finding = buildFinding('f-1', 'analyzed', 'Click me');
+ const onSelectFinding = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Click me'));
+ expect(onSelectFinding).toHaveBeenCalledWith(finding);
+ });
+
+ it('fires onClose when the close button is clicked', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByLabelText(/close/i));
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx
index ff44bfcd0..aa7ebcccc 100644
--- a/apps/azure/src/pages/Dashboard.tsx
+++ b/apps/azure/src/pages/Dashboard.tsx
@@ -4,9 +4,11 @@ import {
buildProcessHubRollups,
hasTeamFeatures,
normalizeProcessHubId,
+ linkFindingsToStateItems,
} from '@variscout/core';
import type { ProcessHub, SustainmentRecord, ControlHandoff } from '@variscout/core';
import type { EvidenceSnapshot } from '@variscout/core';
+import type { Finding } from '@variscout/core';
import type {
ProcessStateItem,
ProcessStateNote,
@@ -32,6 +34,7 @@ import { FileBrowseButton, type FilePickerResult } from '../components/FileBrows
import ProjectCard from '../components/ProjectCard';
import ProcessHubCard from '../components/ProcessHubCard';
import ProcessHubEvidencePanel from '../components/ProcessHubEvidencePanel';
+import EvidenceSheet from '../components/EvidenceSheet';
import ProcessHubReviewPanel from '../components/ProcessHubReviewPanel';
import SampleDataPicker from '../components/SampleDataPicker';
import StateItemNotesDrawer from '../components/StateItemNotesDrawer';
@@ -78,6 +81,11 @@ export const Dashboard: React.FC = ({
| null
>(null);
const [isSavingNote, setIsSavingNote] = useState(false);
+ const [sheetState, setSheetState] = useState<{
+ item: ProcessStateItem;
+ hubId: string;
+ findings: readonly Finding[] | null;
+ } | null>(null);
// Fetch current user ID for task ownership display
useEffect(() => {
@@ -255,7 +263,96 @@ export const Dashboard: React.FC = ({
onOpenProject(action.investigationId);
}
},
- [onOpenProject, safeTrackEvent]
+ [onOpenProject]
+ );
+
+ const loadFindingsForItem = React.useCallback(
+ async (item: ProcessStateItem, hubId: string): Promise => {
+ // Find the rollup for this hub to know which investigations belong to it
+ const rollup = hubRollups.find(r => r.hub.id === hubId);
+ if (!rollup) return [];
+
+ // Resolver mirrors the one used in ProcessHubReviewPanel.notesFor:
+ // per-investigation items use item.investigationIds; aggregate items use all
+ const investigationIds =
+ item.investigationIds && item.investigationIds.length > 0
+ ? item.investigationIds
+ : rollup.investigations.map(i => i.id);
+
+ // Look up each investigation's project name (loadProject uses name+location)
+ const hubProjects = projects.filter(
+ p => normalizeProcessHubId(p.metadata?.processHubId) === normalizeProcessHubId(hubId)
+ );
+
+ // Load findings from each linked investigation in parallel
+ const findingsByInv = new Map();
+ await Promise.all(
+ investigationIds.map(async invId => {
+ const projectMeta = hubProjects.find(p => (p.id || p.name) === invId);
+ if (!projectMeta) return;
+ const project = await loadProject(projectMeta.name, projectMeta.location);
+ if (!project) return;
+ const findings = (project as { findings?: Finding[] }).findings;
+ if (Array.isArray(findings)) {
+ findingsByInv.set(invId, findings);
+ }
+ })
+ );
+
+ // Use the pure aggregator to filter to relevant statuses + match by item
+ const result = linkFindingsToStateItems([item], findingsByInv, () => investigationIds);
+ return result.byItemId.get(item.id) ?? [];
+ },
+ [hubRollups, projects, loadProject]
+ );
+
+ const handleChipClick = React.useCallback(
+ async (item: ProcessStateItem, hubId: string, count: number) => {
+ safeTrackEvent('process_hub.evidence_sheet_opened', {
+ hubId,
+ responsePath: item.responsePath,
+ lens: item.lens,
+ evidenceCount: count,
+ });
+ setSheetState({ item, hubId, findings: null });
+ try {
+ const findings = await loadFindingsForItem(item, hubId);
+ // Race guard: only apply if same item is still selected
+ setSheetState(prev =>
+ prev?.item.id === item.id && prev.hubId === hubId ? { ...prev, findings } : prev
+ );
+ } catch (err) {
+ console.error('[Dashboard] Loading evidence findings failed:', err);
+ setSheetState(prev =>
+ prev?.item.id === item.id && prev.hubId === hubId ? { ...prev, findings: [] } : prev
+ );
+ }
+ },
+ [loadFindingsForItem]
+ );
+
+ const handleFindingSelect = React.useCallback(
+ (item: ProcessStateItem, finding: Finding, hubId: string) => {
+ safeTrackEvent('process_hub.evidence_sheet_finding_clicked', {
+ hubId,
+ lens: item.lens,
+ findingStatus: finding.status,
+ });
+ setSheetState(null);
+ // Navigate to the linked investigation. Use the same heuristic as elsewhere:
+ // item.investigationIds[0], falling back to first hub investigation.
+ const rollup = hubRollups.find(r => r.hub.id === hubId);
+ const targetInvestigationId = item.investigationIds?.[0] ?? rollup?.investigations[0]?.id;
+ if (targetInvestigationId) {
+ onOpenProject(targetInvestigationId);
+ // Best-effort hash for finding deep-link; editor may or may not honor it yet.
+ // Always set — assigning the same value is a no-op in browsers.
+ setTimeout(() => {
+ window.location.hash = `finding-${finding.id}`;
+ }, 100);
+ }
+ },
+ [hubRollups, onOpenProject]
);
const handleRequestAddNote = useCallback((item: ProcessStateItem, hubId: string) => {
@@ -626,6 +723,9 @@ export const Dashboard: React.FC = ({
onRequestEditNote={handleRequestEditNote}
onDeleteNote={handleDeleteNote}
currentUserId={userId}
+ loadFindingsForItem={loadFindingsForItem}
+ onChipClick={handleChipClick}
+ onFindingSelect={handleFindingSelect}
/>
= ({
disabled={isSavingNote}
/>
)}
+
+ {/* Evidence sheet — bottom sheet for finding click-thru */}
+ {sheetState && (
+
+ handleFindingSelect(sheetState.item, finding, sheetState.hubId)
+ }
+ onClose={() => setSheetState(null)}
+ />
+ )}
);
};
diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx
index 916dff71c..cf2b1c927 100644
--- a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx
+++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx
@@ -19,8 +19,14 @@ export interface ProcessHubActionsContract {
}
export interface ProcessHubEvidenceContract {
- findingsFor: (item: ProcessStateItem) => readonly Finding[];
- onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void;
+ /** Sync count for the chip — derived from rollup metadata. */
+ countFor: (item: ProcessStateItem) => number;
+ /** Async load for the sheet — called only when chip is clicked (consumer side). */
+ loadFindingsFor: (item: ProcessStateItem) => Promise;
+ /** Fired when chip is clicked — telemetry only. */
+ onChipClick: (item: ProcessStateItem) => void;
+ /** Fired when user selects a finding in the sheet. */
+ onFindingSelect: (item: ProcessStateItem, finding: Finding) => void;
}
export interface ProcessHubNotesContract {
@@ -157,8 +163,8 @@ const StateItemCard: React.FC<{
item: ProcessStateItem;
action: ResponsePathAction;
onInvoke: (item: ProcessStateItem, action: ResponsePathAction) => void;
- findings: readonly Finding[];
- onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void;
+ count: number;
+ onChipClick: () => void;
notes: readonly ProcessStateNote[];
currentUserId: string;
onRequestAddNote: () => void;
@@ -168,7 +174,7 @@ const StateItemCard: React.FC<{
item,
action,
onInvoke,
- findings,
+ count,
onChipClick,
notes,
currentUserId,
@@ -258,7 +264,7 @@ const StateItemCard: React.FC<{
· {UNSUPPORTED_PILL_LABEL[unsupportedReason]}
)}
- onChipClick(item, findings)} />
+
@@ -348,8 +354,8 @@ export const ProcessHubCurrentStatePanel: React.FC evidence.onChipClick(item)}
notes={notes.notesFor(item)}
currentUserId={notes.currentUserId}
onRequestAddNote={() => notes.onRequestAddNote(item)}
diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx
index 698b0f830..29b69733a 100644
--- a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx
+++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, within } from '@testing-library/react';
+import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { vi } from 'vitest';
import type {
@@ -56,11 +56,17 @@ function makeActions(
return { actionFor, onInvoke };
}
-function makeEvidence() {
- // Stubbed evidence contract for PR #4 tests; chip behavior is tested in PR #5.
+function makeEvidence(
+ overrides: {
+ countFor?: (item: ProcessStateItem) => number;
+ loadFindingsFor?: (item: ProcessStateItem) => Promise;
+ } = {}
+) {
return {
- findingsFor: () => [],
+ countFor: overrides.countFor ?? (() => 0),
+ loadFindingsFor: overrides.loadFindingsFor ?? (async () => []),
onChipClick: vi.fn(),
+ onFindingSelect: vi.fn(),
};
}
@@ -364,17 +370,9 @@ describe('ProcessHubCurrentStatePanel — actions', () => {
});
describe('ProcessHubCurrentStatePanel — evidence chip', () => {
- it('shows the chip with finding count when findingsFor returns non-empty', () => {
+ it('shows the chip with finding count when countFor returns non-zero', () => {
const item = buildItem({ id: 'item-e1', responsePath: 'focused-investigation' });
- const findings = [
- { id: 'f-1' } as unknown as Finding,
- { id: 'f-2' } as unknown as Finding,
- { id: 'f-3' } as unknown as Finding,
- ];
- const evidence = {
- findingsFor: () => findings,
- onChipClick: vi.fn(),
- };
+ const evidence = makeEvidence({ countFor: () => 3 });
render(
{
it('shows singular text for one finding', () => {
const item = buildItem({ id: 'item-e2' });
- const evidence = {
- findingsFor: () => [{ id: 'f-only' } as unknown as Finding],
- onChipClick: vi.fn(),
- };
+ const evidence = makeEvidence({ countFor: () => 1 });
render(
{
expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding');
});
- it('omits the chip when findingsFor returns empty', () => {
+ it('omits the chip when countFor returns zero', () => {
const item = buildItem({ id: 'item-e3' });
- const evidence = {
- findingsFor: () => [],
- onChipClick: vi.fn(),
- };
+ const evidence = makeEvidence({ countFor: () => 0 });
render(
{
expect(screen.queryByTestId('current-state-evidence-chip')).not.toBeInTheDocument();
});
- it('fires onChipClick with item + findings on chip click and stops card propagation', () => {
+ it('fires onChipClick(item) on chip click and stops card propagation', () => {
const item = buildItem({ id: 'item-e4', responsePath: 'focused-investigation' });
- const findings = [{ id: 'f-1' } as unknown as Finding];
const onChipClick = vi.fn();
const onInvoke = vi.fn();
@@ -444,26 +435,24 @@ describe('ProcessHubCurrentStatePanel — evidence chip', () => {
}),
onInvoke,
}}
- evidence={{ findingsFor: () => findings, onChipClick }}
+ evidence={{
+ countFor: () => 1,
+ loadFindingsFor: async () => [],
+ onChipClick,
+ onFindingSelect: vi.fn(),
+ }}
notes={makeNotes()}
/>
);
- const chip = screen.getByTestId('current-state-evidence-chip');
- chip.click();
-
- expect(onChipClick).toHaveBeenCalledWith(item, findings);
- // Card click should NOT have fired because chip stops propagation
+ fireEvent.click(screen.getByTestId('current-state-evidence-chip'));
+ expect(onChipClick).toHaveBeenCalledWith(item);
expect(onInvoke).not.toHaveBeenCalled();
});
it('renders chip on Planned/unsupported cards too (chip independent of action support)', () => {
const item = buildItem({ id: 'item-e5', responsePath: 'measurement-system-work' });
- const findings = [{ id: 'f-1' } as unknown as Finding];
- const evidence = {
- findingsFor: () => findings,
- onChipClick: vi.fn(),
- };
+ const evidence = makeEvidence({ countFor: () => 1 });
render(
{
expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding');
});
+
+ it('does not call loadFindingsFor when only the count is needed for the chip', () => {
+ const loadFindingsFor = vi.fn(async () => []);
+ const item = buildItem();
+
+ render(
+ 5, loadFindingsFor })}
+ notes={makeNotes()}
+ />
+ );
+
+ expect(loadFindingsFor).not.toHaveBeenCalled();
+ });
+
+ it('exposes onFindingSelect on the contract for consumer-side sheet wiring', () => {
+ const evidence = makeEvidence();
+ expect(typeof evidence.onFindingSelect).toBe('function');
+ });
});
describe('ProcessHubCurrentStatePanel — notes', () => {