Skip to content

Commit a60de35

Browse files
committed
Open binder projects with candidate-centered validation navigation
1 parent 7083962 commit a60de35

10 files changed

Lines changed: 444 additions & 15 deletions

‎platform/frontend/src/components/ResultsViewer.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,7 @@ function ResultsViewerContent() {
18191819
const [activeTab, setActiveTab] = useState<TabId>('overview');
18201820
const [rfWorkbenchJob, setRfWorkbenchJob] = useState<string>();
18211821
const requestedDesignId = new URLSearchParams(location.search).get('design_id')?.trim() ?? '';
1822+
const exactJobScope = new URLSearchParams(location.search).get('include_children') === 'false';
18221823
const destinationLaunchContextId = new URLSearchParams(location.search).get('launch_context_id');
18231824
const exactArtifactId = new URLSearchParams(location.search).get('artifact_id');
18241825
const exactTargetState = new URLSearchParams(location.search).get('target_state');
@@ -2525,7 +2526,8 @@ function ResultsViewerContent() {
25252526
include_summary: true,
25262527
...(nativeResults && nativeRetryFilters ? {job_id: nativeRetryFilters.job_id, model_id: nativeRetryFilters.model_id,
25272528
include_children: nativeRetryFilters.include_children, design_ids: nativeRetryFilters.design_ids} : {}),
2528-
}), [nativeRetryFilters, nativeResults, nativeQuery, scopedModelId, selectedJobId, isReviewStageJob, filterText, pageSize, currentPage, apiSortField, sortDir, selectedBackboneId, plddtMin, iptmMin, ipsaeMin, contactsMin, targetContactsMin, epitopeMaxDistValue, targetMaxDistValue, binderSizeMinValue, binderSizeMaxValue, cdrH1MinValue, cdrH1MaxValue, cdrH2MinValue, cdrH2MaxValue, cdrH3MinValue, cdrH3MaxValue, rogMinValue, rogMaxValue, rfdRogMinValue, rfdRogMaxValue, activeRfArtifactGroup, activeSavedSubsetDesignIds, activeJob?.design_count, activeJobHasDesignBearingChildren, backboneFilterApplies, forceBulkLoadForSorting]);
2529+
...(exactJobScope ? { include_children: false } : {}),
2530+
}), [exactJobScope, nativeRetryFilters, nativeResults, nativeQuery, scopedModelId, selectedJobId, isReviewStageJob, filterText, pageSize, currentPage, apiSortField, sortDir, selectedBackboneId, plddtMin, iptmMin, ipsaeMin, contactsMin, targetContactsMin, epitopeMaxDistValue, targetMaxDistValue, binderSizeMinValue, binderSizeMaxValue, cdrH1MinValue, cdrH1MaxValue, cdrH2MinValue, cdrH2MaxValue, cdrH3MinValue, cdrH3MaxValue, rogMinValue, rogMaxValue, rfdRogMinValue, rfdRogMaxValue, activeRfArtifactGroup, activeSavedSubsetDesignIds, activeJob?.design_count, activeJobHasDesignBearingChildren, backboneFilterApplies, forceBulkLoadForSorting]);
25292531
useEffect(() => { setSelectedDesignId(''); setCurrentPage(1); setNativeQuery({}); setNativeRetryFilters(null); }, [selectedJobId, resultSurface]);
25302532
const bulkSelectionFilters = useMemo<DesignFilters>(() => ({
25312533
...designQueryFilters,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
import { Link, useSearchParams } from 'react-router-dom';
3+
import type { CandidateAttempt, ProjectCandidateData, ValidationKind } from '../../lib/projectCandidateOverview';
4+
export { useProjectCandidateData } from '../../lib/projectCandidateOverview';
5+
6+
const stageLabels: Record<ValidationKind, string> = { monomer: 'Monomer', trimer1: 'Trimer · 1 binder', trimer3: 'Trimer · 3 binders', other: 'Other validation' };
7+
const failed = (a: CandidateAttempt) => a.status === 'failed' || a.status === 'cancelled';
8+
function AttemptLinks({ attempts }: { attempts: CandidateAttempt[] }) {
9+
if (!attempts.length) return <span className="text-content-muted" title="No linked result in this inventory; this does not establish that it was not run.">—</span>;
10+
return <ul className="space-y-2">{attempts.map((a, index) => <li key={a.id}>
11+
<Link className="text-accent hover:underline" to={a.href} title={`Job ${a.id}${a.parentId ? `; recorded parent ${a.parentId}` : ''}${a.retryOf ? `; retry of ${a.retryOf}` : ''}`}>
12+
{a.status === 'completed' ? 'View result' : a.status} {attempts.length > 1 ? index + 1 : ''}
13+
</Link><div className="text-xs text-content-secondary">{a.model}{a.retryOf ? ' · retry' : ''}</div>
14+
</li>)}</ul>;
15+
}
16+
export function ProjectCandidateOverview({ data, onOpenRecords }: { data: ProjectCandidateData; onOpenRecords: () => void }) {
17+
const [params, setParams] = useSearchParams();
18+
const [query, setQuery] = useState(() => params.get('candidate_search') ?? '');
19+
const [source, setSource] = useState(() => params.get('candidate_source') ?? '');
20+
const [family, setFamily] = useState(() => params.get('candidate_family') ?? '');
21+
// Keep typing synchronous; router navigation may be scheduled as a transition.
22+
// Persist the committed filters for result -> Back and cold reopening.
23+
useEffect(() => {
24+
const next = new URLSearchParams(params);
25+
for (const [key, value] of Object.entries({ candidate_search: query, candidate_source: source, candidate_family: family })) {
26+
if (value) next.set(key, value); else next.delete(key);
27+
}
28+
if (next.toString() !== params.toString()) setParams(next, { replace: true });
29+
}, [query, source, family, params, setParams]);
30+
const families = useMemo(() => [...new Map(data.candidates.filter(c => !source || c.sourceId === source).map(c => [c.family, c.familyLabel])).entries()], [data.candidates, source]);
31+
const candidates = data.candidates.filter(c => (!source || c.sourceId === source) && (!family || c.family === family)
32+
&& `${c.sourceLabel} ${c.familyLabel} ${c.variant} ${c.nativeId} ${c.length ?? ''}`.toLowerCase().includes(query.toLowerCase().trim()));
33+
return <section className="w-full min-w-0 space-y-5 p-4 md:p-6" aria-label="Candidate overview">
34+
<header className="flex flex-wrap items-start justify-between gap-4">
35+
<div><h2 className="text-xl font-semibold">Candidates with validation work</h2>
36+
<p className="mt-1 text-sm text-content-secondary">Compare the same candidate across target states. These are related validations, not a causal parent chain.</p></div>
37+
<button type="button" onClick={onOpenRecords} className="rounded border border-border-primary px-3 py-2 text-sm">All project records</button>
38+
</header>
39+
<div className="flex flex-wrap gap-3">{data.sources.map((s, index) => <Link key={s.id} to={`/designs/${encodeURIComponent(s.id)}`} title={s.label}
40+
className="rounded-lg border border-border-primary bg-surface-secondary px-4 py-3 text-sm hover:border-accent">
41+
<span className="block font-medium">{s.label.length <= 60 ? s.label : `BC2 source ${index + 1}`}</span><span className="text-accent">Open all original BC2 outputs →</span>
42+
</Link>)}</div>
43+
{data.errors.length > 0 && <div role="alert" className="rounded border border-amber-700 p-3 text-sm">Some native reads are unavailable. This is a partial overview; use Records or refresh to retry.<ul>{data.errors.map(e => <li key={e}>{e}</li>)}</ul></div>}
44+
<div className="flex flex-wrap items-end gap-3">
45+
<label className="min-w-48 flex-1 text-sm">Search candidates<input aria-label="Search candidates" value={query} onChange={e => setQuery(e.target.value)} placeholder="Family, variant, length or native ID" className="mt-1 block w-full rounded border border-border-primary bg-surface p-2" /></label>
46+
<label className="text-sm">Source / site<select aria-label="Source / site" value={source} onChange={e => { setSource(e.target.value); setFamily(''); }} className="mt-1 block rounded border border-border-primary bg-surface p-2"><option value="">All sources</option>{data.sources.map((s, i) => <option key={s.id} value={s.id}>{s.label.length <= 60 ? s.label : `BC2 source ${i + 1}`}</option>)}</select></label>
47+
<label className="text-sm">Family<select aria-label="Family" value={family} onChange={e => setFamily(e.target.value)} className="mt-1 block rounded border border-border-primary bg-surface p-2"><option value="">All families</option>{families.map(([id, label]) => <option key={id} value={id}>{label}</option>)}</select></label>
48+
</div>
49+
<p className="text-sm text-content-secondary">Showing {candidates.length} of {data.candidates.length} candidates with linked validation work. Original output links include candidates outside this table. Completed means execution completed, not scientific acceptance.</p>
50+
<div className="w-full overflow-x-auto rounded-lg border border-border-primary">
51+
<table className="w-full min-w-[980px] text-left text-sm">
52+
<caption className="sr-only">Original BC2 candidates and their independently linked validation results</caption>
53+
<thead className="bg-surface-secondary"><tr>{['Source / site', 'Candidate', 'Original BC2 candidate', 'Monomer result', 'Trimer · 1 binder', 'Trimer · 3 binders', 'Other / history'].map(h => <th key={h} scope="col" className="px-4 py-3 font-medium">{h}</th>)}</tr></thead>
54+
<tbody>{candidates.map(c => <tr key={c.key} className="border-t border-border-primary align-top">
55+
<td className="max-w-48 px-4 py-4"><span title={c.sourceLabel}>{c.sourceLabel.length <= 60 ? c.sourceLabel : `BC2 source ${data.sources.findIndex(s => s.id === c.sourceId) + 1}`}</span></td>
56+
<th scope="row" className="px-4 py-4 font-normal"><div className="font-semibold">{c.familyLabel}</div><div>{c.variant}{c.length !== null ? ` · ${c.length} aa` : ''}</div></th>
57+
<td className="max-w-56 px-4 py-4"><Link to={c.href} className="text-accent hover:underline">Open exact candidate</Link><details className="mt-2 text-xs text-content-secondary"><summary className="cursor-pointer">Native identity</summary><code className="break-all">{c.nativeId}</code></details></td>
58+
{(['monomer', 'trimer1', 'trimer3'] as const).map(kind => <td key={kind} className="px-4 py-4"><AttemptLinks attempts={c.attempts.filter(a => a.kind === kind && !failed(a))} /></td>)}
59+
<td className="px-4 py-4">{c.attempts.some(a => a.kind === 'other' && !failed(a)) && <details><summary className="cursor-pointer">Other validation</summary><AttemptLinks attempts={c.attempts.filter(a => a.kind === 'other' && !failed(a))} /></details>}
60+
{c.attempts.some(failed) ? <details className="mt-1"><summary className="cursor-pointer text-content-secondary">Failed / cancelled ({c.attempts.filter(failed).length})</summary>{c.attempts.filter(failed).map(a => <div key={a.id} className="mt-2"><span className="text-xs text-content-secondary">{stageLabels[a.kind]}</span><AttemptLinks attempts={[a]} /></div>)}</details> : '—'}</td>
61+
</tr>)}</tbody>
62+
</table>
63+
{!candidates.length && <p className="p-6 text-content-secondary">No candidates match this view. Other work and unresolved candidate links remain in Records.</p>}
64+
</div>
65+
<p className="text-xs text-content-secondary">“—” means no linked result shown, not “not run”. {data.unlinkedJobCount > 0 ? `${data.unlinkedJobCount} related jobs could not be assigned to an exact candidate here. ` : ''}Other sources, target references and full provenance remain in <button type="button" onClick={onOpenRecords} className="text-accent underline">Records</button>.</p>
66+
</section>;
67+
}

‎platform/frontend/src/components/project-manager/ProjectInspector.tsx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ interface ProjectInspectorProps {
1919
onRecord: () => void;
2020
}
2121

22-
function operatorFields(value: Record<string, unknown>): Record<string, unknown> {
22+
const narrativeKeys = new Set(['objective', 'research_objective', 'scientific_objective', 'description']);
23+
const normalizedNarrative = (value: string) => value.trim().replace(/\s+/g, ' ');
24+
25+
function operatorFields(value: Record<string, unknown>, existingNarratives = new Set<string>()): Record<string, unknown> {
2326
const technical = /(^schema$|(^|_)(id|ids|digest|generation|revision|receipt|receipts|adapter|raw|uri|route|payload)(_|$))/;
2427
const preferred = ['lifecycle_state', 'status', 'priority', 'objective', 'research_objective', 'scientific_objective', 'scientific_question', 'success_criteria'];
28+
const seenNarratives = new Set(existingNarratives);
2529
const nested = value.domain_payload && typeof value.domain_payload === 'object' && !Array.isArray(value.domain_payload)
2630
? value.domain_payload as Record<string, unknown> : {};
2731
const clean = (item: unknown): unknown => {
@@ -32,6 +36,12 @@ function operatorFields(value: Record<string, unknown>): Record<string, unknown>
3236
return Object.fromEntries(Object.entries({ ...nested, ...value }).filter(([key]) => !technical.test(key) && key !== 'name').map(([key, item]) => [key, clean(item)] as const).sort(([a], [b]) => {
3337
const rank = (key: string) => preferred.includes(key) ? preferred.indexOf(key) : preferred.length;
3438
return rank(a) - rank(b);
39+
}).filter(([key, item]) => {
40+
if (!narrativeKeys.has(key) || typeof item !== 'string') return true;
41+
const text = normalizedNarrative(item);
42+
if (seenNarratives.has(text)) return false;
43+
seenNarratives.add(text);
44+
return true;
3545
}));
3646
}
3747

@@ -77,6 +87,12 @@ export function ProjectInspector({
7787
onRecord,
7888
}: ProjectInspectorProps) {
7989
const selection = summary.selection;
90+
const summaryFields = operatorFields(selection.summary);
91+
const narratives = new Set(Object.entries(summaryFields)
92+
.filter(([key, value]) => narrativeKeys.has(key) && typeof value === 'string')
93+
.map(([, value]) => normalizedNarrative(value as string)));
94+
const scientificFields = operatorFields(selection.scientific_context, narratives);
95+
const subtitleRepeated = selection.subtitle && narratives.has(normalizedNarrative(selection.subtitle));
8096
const treeSelection = summary.tree.nodes.find((node) => node.node_key === selection.node_key);
8197
const actions = new Set([...selection.available_actions, ...(treeSelection?.allowed_actions ?? [])]);
8298
const surface = selection.canonical_surface;
@@ -101,7 +117,7 @@ export function ProjectInspector({
101117
<div className="min-w-0">
102118
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">Selected {displayLabel(selection.node_type)}</p>
103119
<h2 className="mt-1 truncate text-lg font-semibold text-content">{selection.title}</h2>
104-
{selection.subtitle && <p className="mt-1 text-xs leading-5 text-content-secondary">{selection.subtitle}</p>}
120+
{selection.subtitle && !subtitleRepeated && <p className="mt-1 text-xs leading-5 text-content-secondary"><ProjectNarrative text={selection.subtitle} /></p>}
105121
</div>
106122
{onClose && <button type="button" onClick={onClose} className="rounded-md border border-border-primary px-2 py-1 text-xs text-content-secondary xl:hidden">Close</button>}
107123
</div>
@@ -137,11 +153,11 @@ export function ProjectInspector({
137153
)}
138154

139155
<section aria-label="Research summary">
140-
<DetailGrid value={operatorFields(selection.summary)} />
156+
<DetailGrid value={summaryFields} />
141157
</section>
142-
{Object.keys(selection.scientific_context).length > 0 && <section>
158+
{Object.keys(scientificFields).length > 0 && <section>
143159
<h3 className="mb-2 text-sm font-semibold text-content">Scientific context</h3>
144-
<DetailGrid value={operatorFields(selection.scientific_context)} />
160+
<DetailGrid value={scientificFields} />
145161
</section>}
146162

147163
<section>

‎platform/frontend/src/components/project-manager/RelationshipMap.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function MapNodeButton({ node, selected, onSelect, region }: {
3434
<span className="flex items-start justify-between gap-3">
3535
<span className="min-w-0">
3636
<span className="block text-[10px] font-semibold uppercase tracking-[0.16em] text-content-muted">{displayLabel(node.node_type)}</span>
37-
<span className="mt-1 block truncate text-sm font-semibold text-content">{node.label}</span>
37+
<span className="mt-1 block break-words text-sm font-semibold text-content">{node.label}</span>
3838
</span>
3939
<span className="shrink-0 rounded-full border border-border-primary px-2 py-0.5 text-[10px] text-content-secondary">{displayLabel(node.normalized_state)}</span>
4040
</span>
@@ -82,7 +82,7 @@ export function RelationshipMap({ summary, selectedNodeKey, onSelect, onLoadMore
8282
<h3 className="mb-2 text-xs font-semibold text-content-secondary">{group.label} · {group.items.length}</h3>
8383
<div className="space-y-1">{group.items.map((record) => <MapNodeButton key={record.node_key} node={record} selected={selectedNodeKey === record.node_key} onSelect={onSelect} region="record" />)}</div>
8484
</section>)}
85-
{!records.length && <p className="text-xs text-content-muted">No external records are attached to this Domain Experiment. Domain-owned data is separate from this relationship list.</p>}
85+
{!records.length && <p className="text-xs text-content-muted">Records are not shown in this map page. Select the experiment to inspect its results and notes.</p>}
8686
</div>
8787
</section>;
8888
};
@@ -122,7 +122,7 @@ export function RelationshipMap({ summary, selectedNodeKey, onSelect, onLoadMore
122122
<div aria-hidden="true" className="mx-auto h-5 w-px bg-border-secondary" />
123123
<div className="grid gap-5 lg:grid-cols-2">
124124
{domains.map((domain) => renderDomain(domain))}
125-
{!domains.length && <p className="col-span-full text-center text-xs text-content-muted">This Global Experiment has no visible Domain Experiments.</p>}
125+
{!domains.length && <p className="col-span-full text-center text-xs text-content-muted">Select this group to view its experiments.</p>}
126126
</div>
127127
</section>;
128128
})}

0 commit comments

Comments
 (0)