From 0c9ff5b4c6061b4cf4f4403e1e26fe2b6834068b Mon Sep 17 00:00:00 2001
From: Jukka-Matti Turtiainen
Date: Tue, 28 Apr 2026 10:12:11 +0300
Subject: [PATCH 1/5] refactor(ui): split ProcessHubEvidenceContract into
countFor + loadFindingsFor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the synthetic-Finding placeholder pattern from PR #99 with a
clean separation of concerns:
- countFor (sync) — chip badge count, derived from rollup metadata
- loadFindingsFor (async) — fetches real findings for the sheet, only
called when consumer wires it after chip click
- onChipClick(item) — telemetry signal, no findings arg
- onFindingSelect(item, finding) — sheet's selection callback
Sheet management stays on the consumer side (next task wires Dashboard).
Eliminates the 'as unknown as readonly Finding[]' cast and the synthetic-
placeholder generation entirely.
Existing chip tests rewritten for the new contract; 2 new tests added
(no-load-on-render guard + onFindingSelect contract presence).
Phase 3 PR #2, Task 2.
Co-Authored-By: ruflo
---
.../ProcessHubCurrentStatePanel.tsx | 22 +++--
.../ProcessHubCurrentStatePanel.test.tsx | 82 +++++++++++--------
2 files changed, 60 insertions(+), 44 deletions(-)
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', () => {
From 56f40ab2382026ab86dcd515f438b3306f774d64 Mon Sep 17 00:00:00 2001
From: Jukka-Matti Turtiainen
Date: Tue, 28 Apr 2026 10:19:47 +0300
Subject: [PATCH 2/5] feat(azure): add EvidenceSheet bottom sheet for finding
click-thru
Lightweight sheet listing findings (label + status badge + click-thru).
Renders nothing when item is null; loading state when findings is null;
empty state for zero findings. Esc key, click-outside, and close button
all fire onClose.
Phase 3 PR #2, Task 3.
Co-Authored-By: ruflo
---
apps/azure/src/components/EvidenceSheet.tsx | 89 ++++++++++++++++++
.../__tests__/EvidenceSheet.test.tsx | 94 +++++++++++++++++++
2 files changed, 183 insertions(+)
create mode 100644 apps/azure/src/components/EvidenceSheet.tsx
create mode 100644 apps/azure/src/components/__tests__/EvidenceSheet.test.tsx
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/__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();
+ });
+});
From d8b446f8a4decb6101c1b7e2b498a9de2b03a4d4 Mon Sep 17 00:00:00 2001
From: Jukka-Matti Turtiainen
Date: Tue, 28 Apr 2026 10:32:28 +0300
Subject: [PATCH 3/5] feat(azure): wire EvidenceSheet with lazy finding load on
chip click
Replaces PR #99's synthetic-Finding placeholder pattern with a real
async load through the existing useStorage().loadProject() chain.
ProcessHubReviewPanel:
- countFor derives chip count from ProcessHubInvestigationMetadata.findingCounts
(same arithmetic as the previous synthetic-placeholder length)
- loadFindingsForItem / onChipClick / onFindingSelect pass-through to Dashboard
- removes the synthetic Finding[] cast and TODO(evidence-sheet-pr) comment
- removes safeTrackEvent import (no longer needed in this component)
Dashboard:
- sheetState managed at Dashboard level (item + hubId + findings | null)
- handleChipClick: fires telemetry, opens sheet in loading state, async-loads
via loadProject() chain, applies findings with a race guard
- handleFindingSelect: fires telemetry, closes sheet, navigates via existing
onOpenProject + best-effort URL hash for finding deep-link
- EvidenceSheet rendered at Dashboard level (sibling of StateItemNotesDrawer)
Telemetry: process_hub.evidence_sheet_{opened,finding_clicked} with non-PII
payloads (hubId from rollup.hub.id, lens, responsePath, findingStatus enum
in finding-click event). No PII per ADR-059.
Phase 3 PR #2, Task 4.
Co-Authored-By: ruflo
---
.../src/components/ProcessHubReviewPanel.tsx | 65 +++-------
apps/azure/src/pages/Dashboard.tsx | 114 +++++++++++++++++-
2 files changed, 133 insertions(+), 46 deletions(-)
diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx
index ff728744b..747fddc4a 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,10 @@ 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;
+ onChipClick: (item: ProcessStateItem, hubId: string) => void;
+ onFindingSelect: (item: ProcessStateItem, finding: Finding, hubId: string) => void;
}
const SnapshotCard: React.FC<{
@@ -65,6 +68,9 @@ const ProcessHubReviewPanel: React.FC = ({
onRequestEditNote,
onDeleteNote,
currentUserId,
+ loadFindingsForItem,
+ onChipClick,
+ onFindingSelect,
}) => {
const cadence = buildProcessHubCadence(rollup);
const currentState = buildCurrentProcessState(rollup, cadence);
@@ -104,34 +110,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 +149,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 +185,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),
+ onFindingSelect: (item, finding) => onFindingSelect(item, finding, rollup.hub.id),
+ }}
notes={{
notesFor,
onRequestAddNote: item => onRequestAddNote(item, rollup.hub.id),
diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx
index ff44bfcd0..3888dc42a 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) => {
+ safeTrackEvent('process_hub.evidence_sheet_opened', {
+ hubId,
+ responsePath: item.responsePath,
+ lens: item.lens,
+ });
+ 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.
+ setTimeout(() => {
+ if (window.location.hash !== `#finding-${finding.id}`) {
+ 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)}
+ />
+ )}
);
};
From 73619ab1e8b931e51f93fc9f2c46b6542a9e54cc Mon Sep 17 00:00:00 2001
From: Jukka-Matti Turtiainen
Date: Tue, 28 Apr 2026 10:38:38 +0300
Subject: [PATCH 4/5] fix(azure): drop dead URL-hash guard in
handleFindingSelect
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The guard `window.location.hash !== '#finding-${id}'` (with `#`) compared
against an assignment without `#` — the comparison was always false so
the guard never short-circuited. Assignment is idempotent in browsers
anyway; just drop the guard.
Phase 3 PR #2, code-review followup.
Co-Authored-By: ruflo
---
apps/azure/src/pages/Dashboard.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx
index 3888dc42a..5a94b5c07 100644
--- a/apps/azure/src/pages/Dashboard.tsx
+++ b/apps/azure/src/pages/Dashboard.tsx
@@ -345,10 +345,9 @@ export const Dashboard: React.FC = ({
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(() => {
- if (window.location.hash !== `#finding-${finding.id}`) {
- window.location.hash = `finding-${finding.id}`;
- }
+ window.location.hash = `finding-${finding.id}`;
}, 100);
}
},
From 6d446369c74b27e2fec6bdea82fd4a393f7d7b78 Mon Sep 17 00:00:00 2001
From: Jukka-Matti Turtiainen
Date: Tue, 28 Apr 2026 10:50:16 +0300
Subject: [PATCH 5/5] fix(azure): include evidenceCount in
process_hub.evidence_sheet_opened telemetry
The Phase 3 spec's telemetry table specified payload {hubId, responsePath,
lens, evidenceCount} but the implementation omitted evidenceCount.
evidenceCount is the key engagement metric for the chip feature.
Threads count through the panel's onChipClick(item, hubId, count) so
Dashboard can include it in safeTrackEvent without re-deriving.
Phase 3 PR #2, code-review followup.
Co-Authored-By: ruflo
---
apps/azure/src/components/ProcessHubReviewPanel.tsx | 5 +++--
apps/azure/src/pages/Dashboard.tsx | 3 ++-
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx
index 747fddc4a..217cdc12c 100644
--- a/apps/azure/src/components/ProcessHubReviewPanel.tsx
+++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx
@@ -33,7 +33,8 @@ interface ProcessHubReviewPanelProps {
currentUserId: string;
/** Evidence wiring (PR #2) — async finding loader; Dashboard owns sheet state. */
loadFindingsForItem: (item: ProcessStateItem, hubId: string) => Promise;
- onChipClick: (item: ProcessStateItem, hubId: string) => void;
+ /** 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;
}
@@ -188,7 +189,7 @@ const ProcessHubReviewPanel: React.FC = ({
evidence={{
countFor,
loadFindingsFor: item => loadFindingsForItem(item, rollup.hub.id),
- onChipClick: item => onChipClick(item, rollup.hub.id),
+ onChipClick: item => onChipClick(item, rollup.hub.id, countFor(item)),
onFindingSelect: (item, finding) => onFindingSelect(item, finding, rollup.hub.id),
}}
notes={{
diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx
index 5a94b5c07..aa7ebcccc 100644
--- a/apps/azure/src/pages/Dashboard.tsx
+++ b/apps/azure/src/pages/Dashboard.tsx
@@ -307,11 +307,12 @@ export const Dashboard: React.FC = ({
);
const handleChipClick = React.useCallback(
- async (item: ProcessStateItem, hubId: string) => {
+ 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 {