Assessor_2.0/src/features/dataset-viewer/DatasetViewer.tsx

493 lines
25 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { previousPolicyReviews } from '../review-workspace/database-review';
import { useApp } from '../../app/AppProvider';
import { TriangleIcon } from '../../components/TriangleIcon';
import { DataTable } from '../../components/DataTable/DataTable';
import { FindingCard } from '../../components/FindingCard/FindingCard';
import { TileCard } from '../../components/TileCard/TileCard';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { maesterFindingTitle } from '../../profiles/maester/presentation';
import { cisBenchmarkRuleId } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisSectionHeading } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { recordResultSheets } from '../linked-results/record-results';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
import { FindingReader } from '../finding-reader/FindingReader';
import { ClusterSlotIcon, FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
import {
resultClusterParkingPosition,
RESULT_CLUSTER_ROW_HEIGHT,
RESULT_DOCK_SLOT_ID,
detachResultWindow,
prepareForNewClusterWindow,
restoreResultWindow,
toggleResultWindowMinimized
} from '../linked-results/result-window-layout';
import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { useVirtualRows } from '../../hooks/use-virtual-rows';
import { toolIdForRecord } from '../../importers/status-normalization';
/**
* Geschaetzte Zeilenhoehe fuer die Fenster-Berechnung der Trefferliste
* (Datensaetze-Ansicht). Dient nur dem Scroll-Mass der Platzhalter, nicht dem
* tatsaechlichen Layout der sichtbaren Karten - eine Abweichung durch
* zweizeilige Titel bleibt durch den Overscan unbemerkbar.
*/
const RECORD_ROW_HEIGHT = 78;
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
const TILE_MIN_WIDTH = 358;
/** Verbindliche Mindesthoehe der regulaeren Finding-Kachel. */
const TILE_MIN_HEIGHT = 223;
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
const TILE_GAP = 12;
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
const TILE_GRID_INSET = 20;
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
// Enthielten Violet und Purple; beide ersetzt, weil violett als UI-Rolle
// vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
// "UI segments and tiles").
const resultColors = [
'#0e7490', // Cyan
'#059669', // Emerald
'#dc2626', // Red
'#2563eb', // Blue
'#ea580c', // Orange
'#0d9488', // Teal
'#be185d', // Rose
'#16a34a', // Green
'#d946ef', // Magenta
'#b45309', // Amber
'#4f46e5', // Indigo
'#ca8a04', // Gold
'#0369a1', // Sky Blue
'#78350f', // Brown
'#65a30d', // Lime
'#475569' // Slate
];
interface OpenResultWindow {
recordId: string;
/** Werkzeugprofil des einzelnen Findings, auch in generischen Sammelblaettern. */
toolId?: string;
title: string;
sheets: DatasetSheet[];
/** Ergebnistexte des Datensatzes (siehe `resultSectionsFor`). */
sections: ResultSection[];
color: string;
zIndex: number;
/** Nur die Kopfzeile sichtbar, manuell umgeschaltet. */
minimized: boolean;
/** Maximiert und aus dem Cluster in eine freie Position gezogen. */
detached: boolean;
}
import { RawDataView } from './RawDataView';
import { measureNavigationStep } from '../../app/navigation-diagnostics';
export function DatasetViewer() {
const app = useApp();
const { dataset, activeSheet, view, setView, search, titleColumn, selectedRecordId, setSelectedRecord, reviews, updateReview } = app;
const [resultWindows, setResultWindows] = useState<OpenResultWindow[]>([]);
const [clusterPos, setClusterPos] = useState<{ left: number; top: number } | null>(null);
const [clusterWidth, setClusterWidth] = useState<number>(760);
/**
* Eingeklappter Verbund: die Miniaturen verschwinden aus der Arbeitsflaeche
* und liegen als schmale Leiste in der Filterzeile. Frei herausgezogene
* Fenster bleiben davon unberuehrt - sie gehoeren nicht zum Verbund.
*/
const [clusterDocked, setClusterDocked] = useState(false);
const [dockSlot, setDockSlot] = useState<HTMLElement | null>(null);
const [viewportSize, setViewportSize] = useState(() => ({
width: window.innerWidth,
height: window.innerHeight
}));
const parkingAnchorRef = useRef<HTMLSpanElement>(null);
const [workSurfaceTop, setWorkSurfaceTop] = useState(0);
const nextZIndex = useRef(2100);
const records = useFilteredRecords();
const minimizedWindows = useMemo(() => resultWindows.filter(w => w.minimized && !w.detached), [resultWindows]);
const clusteredWindows = useMemo(() => resultWindows.filter(w => !w.detached), [resultWindows]);
const effectiveClusterWidth = Math.min(clusterWidth, Math.max(0, viewportSize.width - 12));
const clusterStackHeight = Math.max(RESULT_CLUSTER_ROW_HEIGHT, minimizedWindows.length * RESULT_CLUSTER_ROW_HEIGHT);
const maxClusterLeft = Math.max(0, viewportSize.width - effectiveClusterWidth);
const maxClusterTop = Math.max(0, viewportSize.height - clusterStackHeight - 12);
// Minimierte Results parken rechts oben innerhalb der Arbeitsflaeche statt
// im darueberliegenden Filter- und Statusband. Der Cluster bleibt danach
// frei verschiebbar, aber kann nicht in die Kopfzone zurueckgezogen werden.
const defaultClusterPos = useMemo(() => resultClusterParkingPosition(
viewportSize.width,
effectiveClusterWidth,
maxClusterTop,
workSurfaceTop
), [effectiveClusterWidth, maxClusterTop, viewportSize.width, workSurfaceTop]);
const minClusterTop = defaultClusterPos.top;
const requestedClusterPos = clusterPos ?? defaultClusterPos;
const effectiveClusterPos = {
left: Math.max(0, Math.min(requestedClusterPos.left, maxClusterLeft)),
top: Math.max(minClusterTop, Math.min(requestedClusterPos.top, maxClusterTop))
};
const resultColorsByRecord = useMemo(() => new Map(resultWindows.map(item => [item.recordId, item.color])), [resultWindows]);
// Kacheln: die Spaltenzahl treibt sowohl das CSS-Grid als auch die
// Fenstermathematik - deshalb hier gemessen statt `auto-fill` dem Browser
// zu ueberlassen, das JS sonst nicht kennen wuerde.
const tileScrollRef = useRef<HTMLDivElement>(null);
const [tileColumns, setTileColumns] = useState(1);
useLayoutEffect(() => {
const container = tileScrollRef.current;
if (!container || view !== 'columns') return;
const compute = () => {
const contentWidth = container.clientWidth - TILE_GRID_INSET * 2;
if (container.clientWidth > 0) {
setTileColumns(Math.max(1, Math.floor((contentWidth + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
}
};
compute();
const resizeObserver = new ResizeObserver(compute);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [view, activeSheet, dataset]);
const tileRowCount = Math.ceil(records.length / tileColumns);
const tileWindow = useVirtualRows(tileRowCount, TILE_ROW_HEIGHT, 2);
// Zwei Refs auf demselben Knoten: `tileScrollRef` misst die Breite fuer die
// Spaltenzahl, `tileWindow.containerRef` haengt den Scroll-Listener der
// Fenster-Berechnung an.
const setTileScrollNode = useCallback((node: HTMLDivElement | null) => {
tileScrollRef.current = node;
tileWindow.containerRef(node);
if (node && node.clientWidth > 0) {
const contentWidth = node.clientWidth - TILE_GRID_INSET * 2;
setTileColumns(Math.max(1, Math.floor((contentWidth + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
}
}, [tileWindow.containerRef]);
// Datensaetze: die aktive Karte kann ausserhalb des gerenderten Fensters
// liegen (Weiter/Zurueck im Leser, oder Einstieg aus den Kacheln auf einen
// weit unten stehenden Fund) - dann muss die Liste selbst dorthin scrollen,
// die einzelne Karte kann das nicht mehr selbst tun, wenn sie noch gar
// nicht gemountet ist.
const recordWindow = useVirtualRows(records.length, RECORD_ROW_HEIGHT, 8);
useEffect(() => {
if (view !== 'records') return;
const index = records.findIndex(record => record.id === selectedRecordId);
if (index >= 0) recordWindow.scrollToIndex(index);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, selectedRecordId]);
useEffect(() => {
const updateViewportSize = () => setViewportSize({
width: window.innerWidth,
height: window.innerHeight
});
window.addEventListener('resize', updateViewportSize);
return () => window.removeEventListener('resize', updateViewportSize);
}, []);
useLayoutEffect(() => {
const anchor = parkingAnchorRef.current;
if (!anchor) return;
const measure = () => setWorkSurfaceTop(anchor.getBoundingClientRect().top);
measure();
const observer = new ResizeObserver(measure);
observer.observe(anchor.parentElement ?? anchor);
window.addEventListener('resize', measure);
return () => {
observer.disconnect();
window.removeEventListener('resize', measure);
};
}, [activeSheet, dataset, view]);
useEffect(() => {
if (view === 'records') {
setClusterPos(null);
}
}, [view]);
useEffect(() => {
if (view !== 'records') return;
setResultWindows(current => restoreResultWindow(current, selectedRecordId));
}, [view, selectedRecordId]);
/* Die Filterzeile steht in der Shell und ist beim ersten Effekt bereits
eingehaengt - der Viewer erscheint nur gemeinsam mit ihr. */
useEffect(() => setDockSlot(document.getElementById(RESULT_DOCK_SLOT_ID)), []);
/* Ein leerer Verbund bleibt nicht eingeklappt: sonst haette das naechste
geoeffnete Result keine sichtbare Ablage. */
useEffect(() => {
if (!clusteredWindows.length) setClusterDocked(false);
}, [clusteredWindows.length]);
if (!dataset || !activeSheet) return <EmptyWorkspace />;
const recordTitle = (record: FindingRecord) => {
const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId);
if (recordToolId === 'maester') return maesterFindingTitle(record, candidate);
// CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID.
return recordToolId === 'cis-cat' ? cisSectionHeading(record, cisBenchmarkRuleId(record, recordToolId), candidate) : candidate;
};
const selected = records.find(record => record.id === selectedRecordId) ?? records[0];
const toolId = activeSheet.toolId ?? dataset.profileId;
const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId);
const messageFor = (record: FindingRecord) => resultMessageFor(record, activeSheet.toolId ?? dataset.profileId);
const sectionsFor = (record: FindingRecord) => resultSectionsFor(record, activeSheet.toolId ?? dataset.profileId);
const hasResults = (record: FindingRecord) => measureNavigationStep('result-links', () => linksFor(record).length > 0 || sectionsFor(record).length > 0);
const activateWindow = (recordId: string) => setResultWindows(current => current.map(item => item.recordId === recordId ? { ...item, zIndex: ++nextZIndex.current } : item));
const openResults = (record: FindingRecord) => {
const sheets = linksFor(record);
const sections = sectionsFor(record);
const recordToolId = toolIdForRecord(record, toolId);
if (!sheets.length && !sections.length) return;
// Wer ein Result anfasst, will es sehen: der eingeklappte Verbund geht auf.
setClusterDocked(false);
const total = sheets.reduce((sum, sheet) => sum + sheet.analysis.records.length, 0);
// Der gesamte Zustandsuebergang laeuft im Updater, nicht ueber `resultWindows`
// aus dem Render: `TileCard` ist memoisiert und vergleicht `onResults` nur auf
// Vorhandensein (siehe TileCard.tsx), haelt also dauerhaft die Closure des
// ersten Renders. Ein Lesen des Zustands hier waere daher immer die leere
// Anfangsliste - dadurch bekam jedes Fenster dieselbe erste Farbe und ein
// erneuter Klick schloss das Fenster nicht mehr.
setResultWindows(current => {
if (current.some(item => item.recordId === record.id)) {
return current.filter(item => item.recordId !== record.id);
}
const usedColors = new Set(current.map(item => item.color));
const assignedColor = resultColors.find(candidate => !usedColors.has(candidate))
?? resultColors[current.length % resultColors.length];
// Im Cluster bleibt genau ein Fenster maximiert. Bereits frei
// herausgezogene Fenster werden dabei nicht veraendert und koennen
// gleichzeitig maximiert bleiben.
return [
...prepareForNewClusterWindow(current),
{
recordId: record.id,
toolId: recordToolId,
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) \u2013 ${recordTitle(record)}` : `Result \u2013 ${recordTitle(record)}`,
sheets,
sections,
color: assignedColor,
zIndex: ++nextZIndex.current,
minimized: false,
detached: false
}
];
});
};
const resultColor = (recordId: string) => resultColorsByRecord.get(recordId);
const toggleMinimize = (recordId: string) => {
if (resultWindows.some(window => window.recordId === recordId && !window.minimized)) setClusterPos(defaultClusterPos);
setResultWindows(current => toggleResultWindowMinimized(current, recordId));
};
const toggleBookmark = (recordId: string) => {
const isBookmarked = Boolean(reviews[recordId]?.include);
updateReview(recordId, { include: !isBookmarked });
};
const bookmarkCluster = () => {
const allBookmarked = minimizedWindows.length > 0 && minimizedWindows.every(w => Boolean(reviews[w.recordId]?.include));
minimizedWindows.forEach(w => {
updateReview(w.recordId, { include: !allBookmarked });
});
};
/**
* Springt vom Ergebnisfenster zum Datensatz, dessen Beleg es zeigt. Ist die
* Report-Ansicht bereits offen, fuehrt dieselbe Aktion zurueck in die Matrix.
* Der Ansichtswechsel stellt das zugehoerige Cluster-Fenster her. Frei
* herausgezogene Fenster bleiben maximiert und an ihrer Position.
*/
const openRecord = (recordId: string) => {
setSelectedRecord(recordId);
setResultWindows(current => restoreResultWindow(current, recordId));
setView('records');
};
// Berechnete Geometrie der Virtualisierung: als benannte Werte statt eines
// literalen Inline-Style-Objekts, damit die Darstellung sonst in
// src/styles bleibt (siehe FloatingResultsWindow.tsx fuer denselben
// Kompromiss bei einer anderen laufzeitberechneten Position).
const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, var(--tile-content-width))` };
const tileTopSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingTop };
const tileBottomSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingBottom };
const recordTopSpacerStyle = { height: recordWindow.paddingTop };
const recordBottomSpacerStyle = { height: recordWindow.paddingBottom };
// Die Fensterfarbe stammt aus einer Laufzeitpalette und kann deshalb nicht
// in src/styles stehen - wie in FloatingResultsWindow wird nur der Farbwert
// als benannte Eigenschaft durchgereicht, die Darstellung bleibt im CSS.
const dockMarkStyle = (color: string) => ({ '--result-color': color } as unknown as CSSProperties);
return (
<>
<span ref={parkingAnchorRef} className="results-parking-anchor" aria-hidden="true" />
{!records.length ? (
<div className="work-filter-empty" role="status">
<h3>No results match the current filters</h3>
<p>The selected sheet contains {activeSheet.analysis.records.length.toLocaleString('en-US')} saved results. GPO comparisons awaiting review or re-evaluation are available under Result Status: All.</p>
<button className="btn" type="button" onClick={() => {
const source = app.facetFilters['provenance:source-scan'];
app.resetFilters();
app.setStatusFilter('all');
app.setActionOnly(false);
app.setSearch('');
app.setPathFindingIds([]);
if (source) app.setFacetFilter('provenance:source-scan', source);
}}>Show all results for this source</button>
</div>
) : view === 'columns' ? (
<div className="viewer-scroll tile-view-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
{tileWindow.paddingTop > 0 && <div aria-hidden="true" style={tileTopSpacerStyle} />}
{records.slice(tileWindow.startIndex * tileColumns, tileWindow.endIndex * tileColumns).map(record => (
<TileCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} resultColor={resultColor(record.id)} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => {
setSelectedRecord(record.id);
setView('records');
}} onBookmark={() => updateReview(record.id, { include: !reviews[record.id]?.include })} onResults={hasResults(record) ? () => openResults(record) : undefined} />
))}
{tileWindow.paddingBottom > 0 && <div aria-hidden="true" style={tileBottomSpacerStyle} />}
</div></div>
) : view === 'records' && selected ? (
<div className="records-layout">
<div className="record-list" ref={recordWindow.containerRef}>
{recordWindow.paddingTop > 0 && <div aria-hidden="true" style={recordTopSpacerStyle} />}
{records.slice(recordWindow.startIndex, recordWindow.endIndex).map(record => <FindingCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} selected={record.id === selected.id} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => setSelectedRecord(record.id)} />)}
{recordWindow.paddingBottom > 0 && <div aria-hidden="true" style={recordBottomSpacerStyle} />}
</div>
<FindingReader
record={selected}
title={recordTitle(selected)}
review={reviews[selected.id]}
previousPolicyReviews={previousPolicyReviews(selected, reviews)}
linkedSheets={linksFor(selected)}
resultReferences={[...resultReferenceValues(selected), ...pingCastleSectionIds(selected)].filter(value => value.trim() !== (messageFor(selected) ?? '').trim())}
search={search}
position={records.indexOf(selected)}
recordCount={records.length}
toolId={activeSheet?.toolId ?? dataset?.profileId}
onPrevious={() => setSelectedRecord(records[Math.max(0, records.indexOf(selected) - 1)].id)}
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
onReview={patch => updateReview(selected.id, patch)}
onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined}
onResults={hasResults(selected) ? () => openResults(selected) : undefined}
resultsOpen={Boolean(resultColor(selected.id))}
resultColor={resultColor(selected.id)}
/>
</div>
) : view === 'table' ? (
<DataTable key={activeSheet.id} sheet={activeSheet} records={records} search={search} />
) : (
<RawDataView records={records} raw={activeSheet.raw || dataset.rawText}
filtered={Boolean(search || app.actionOnly || app.statusFilter !== 'all' || Object.keys(app.facetFilters).length)} search={search}/>
)}
{clusterDocked && dockSlot && createPortal(
<div className="result-dock">
<button className="result-dock-toggle" type="button" onClick={() => setClusterDocked(false)} title="Results wieder aufklappen" aria-label="Results wieder aufklappen" aria-expanded="false"><ClusterSlotIcon /></button>
<span className="result-dock-count">{clusteredWindows.length}</span>
<span className="result-dock-marks">
{clusteredWindows.map(item => (
<button
key={item.recordId}
className="result-dock-mark"
type="button"
style={dockMarkStyle(item.color)}
title={item.title}
aria-label={item.title}
onClick={() => {
setClusterDocked(false);
setResultWindows(current => restoreResultWindow(current, item.recordId));
}}
>
<TriangleIcon direction="right" />
</button>
))}
</span>
</div>,
dockSlot
)}
{resultWindows.map((item, index) => {
// Eingeklappt verschwindet der Verbund aus der Arbeitsflaeche; frei
// herausgezogene Fenster bleiben stehen, sie gehoeren nicht dazu.
if (clusterDocked && !item.detached) return null;
const minimizedIndex = item.minimized ? minimizedWindows.findIndex(w => w.recordId === item.recordId) : -1;
// Das geoeffnete Fenster und die erste Minimierung teilen sich die
// Oberkante (beide Index 0); die erste Minimierung liegt also genau
// dahinter und zeigt nur ihren farbigen Reiter links daneben. Jede
// weitere Minimierung sitzt eine Kopfzeilenhoehe tiefer.
const effectiveClusterIndex = item.minimized ? minimizedIndex : 0;
// Die Verbund-Bedienelemente haengen immer an der ersten Minimierung,
// damit sie an der Cluster-Kante stehen bleiben, auch wenn das
// geoeffnete Fenster frei herausgezogen wurde. Sie erscheinen erst ab
// zwei Cluster-Fenstern; frei herausgezogene Fenster zaehlen nicht mit.
const isClusterTop = item.minimized && minimizedIndex === 0;
const isClusterBottom = item.minimized && minimizedIndex === minimizedWindows.length - 1;
const windowRecord = records.find(record => record.id === item.recordId);
return (
<FloatingResultsWindow
key={item.recordId}
id={`results-${item.recordId}`}
title={item.title}
sheets={item.sheets}
toolId={item.toolId}
outcome={windowRecord ? resultOutcomeFor(windowRecord, toolId) : undefined}
sections={item.sections}
color={item.color}
zIndex={item.zIndex}
offset={index * 24}
search={search}
onActivate={() => activateWindow(item.recordId)}
onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))}
onOpenRecord={() => openRecord(item.recordId)}
actionLabel="To Report"
actionTitle="Jump to this finding's record in the Report"
minimized={item.minimized}
onToggleMinimize={() => toggleMinimize(item.recordId)}
detached={item.detached}
onDetach={() => setResultWindows(current => detachResultWindow(current, item.recordId))}
clusterIndex={effectiveClusterIndex >= 0 ? effectiveClusterIndex : undefined}
isClusterBottom={isClusterBottom}
clusterCount={clusteredWindows.length}
clusterPosition={effectiveClusterPos}
clusterWidth={effectiveClusterWidth}
isClusterTop={isClusterTop}
onMoveCluster={(dx, dy) => setClusterPos(current => {
const cur = current ?? defaultClusterPos;
return {
left: Math.max(0, Math.min(cur.left + dx, maxClusterLeft)),
top: Math.max(minClusterTop, Math.min(cur.top + dy, maxClusterTop))
};
})}
onResizeClusterWidth={(newW, dxLeft) => {
setClusterWidth(newW);
if (dxLeft) {
setClusterPos(current => {
const position = current ?? defaultClusterPos;
return { ...position, left: Math.max(0, position.left + dxLeft) };
});
}
}}
bookmarked={Boolean(reviews[item.recordId]?.include)}
onToggleBookmark={() => toggleBookmark(item.recordId)}
allClusterBookmarked={minimizedWindows.length > 0 && minimizedWindows.every(w => Boolean(reviews[w.recordId]?.include))}
onBookmarkCluster={bookmarkCluster}
onCloseCluster={() => setResultWindows(current => current.filter(candidate => candidate.detached))}
onDockCluster={() => setClusterDocked(true)}
/>
);
})}
</>
);
}