diff --git a/python/tests/test_workspace_suggestions.py b/python/tests/test_workspace_suggestions.py
index c884c59..0ef8c32 100644
--- a/python/tests/test_workspace_suggestions.py
+++ b/python/tests/test_workspace_suggestions.py
@@ -104,6 +104,19 @@ def test_existing_cell_addition_has_zero_cell_delta_and_explicit_apply(self):
self.assertEqual(response.status_code, 200, response.get_json())
self.assertEqual(response.get_json()['protocol']['counts']['epochs'], 3)
self.assertEqual(self.suggestions()['suggestions'][0]['status'], 'applied')
+ approved = self.suggestions()['approved_history']
+ self.assertEqual(len(approved), 1)
+ self.assertEqual(approved[0]['candidate_revision_uuid'], suggestion['candidate_revision_uuid'])
+ # A newer proposal must not erase the previous approval from the log.
+ row = copy.deepcopy(self.case.suggestion_rows.rows[0])
+ row['suggestion_uuid'] = str(uuid.uuid4())
+ row['summary']['candidate_revision_uuid'] = str(uuid.uuid4())
+ row['summary']['created_at'] = '2099-01-01T00:00:00+00:00'
+ self.case.suggestion_rows.insert1(row)
+ after = self.suggestions()
+ self.assertNotEqual(after['suggestions'][0]['candidate_revision_uuid'], suggestion['candidate_revision_uuid'])
+ self.assertEqual(after['approved_history'][0]['candidate_revision_uuid'], suggestion['candidate_revision_uuid'])
+
def test_candidate_failure_does_not_relabel_committed_import_as_failed(self):
with patch.object(self.case.service, 'explore_preview', side_effect=ValueError('Unsupported new metadata')):
diff --git a/python/workspace_suggestions.py b/python/workspace_suggestions.py
index 276f849..47fdfaa 100644
--- a/python/workspace_suggestions.py
+++ b/python/workspace_suggestions.py
@@ -161,6 +161,22 @@ def list(self):
else:
status = 'pending'
item['status'] = status
- return {'suggestions': list(latest.values()),
+ approvals = {}
+ for event in (self.history.Event & {'project_uuid': self.project_uuid, 'action': 'protocol_dataset_bound'}).to_dicts():
+ payload = event.get('payload') or {}
+ if payload.get('reason') == 'pre_import_baseline_freeze':
+ continue
+ key = (payload.get('protocol_uuid'), payload.get('revision_uuid'))
+ approvals[key] = event.get('occurred_at')
+ approved_history = []
+ seen = set()
+ for row in rows:
+ item = copy.deepcopy(row['summary'])
+ key = (item['protocol_uuid'], item['candidate_revision_uuid'])
+ if key in approvals and key not in seen:
+ item.update(status='applied', approved_at=str(approvals[key]))
+ approved_history.append(item)
+ seen.add(key)
+ return {'approved_history': approved_history, 'suggestions': list(latest.values()),
'counts': {status: sum(item['status'] == status for item in latest.values())
for status in ('pending', 'applied', 'superseded', 'stale')}}
diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx
index 9c773a8..7bafbb8 100644
--- a/workspace-app/src/App.jsx
+++ b/workspace-app/src/App.jsx
@@ -85,11 +85,11 @@ function Protocol({id,revision,onChange,onTagFilter,initialRecipe,initialInspect
{treeLayout.save.status==='saving'?'Saving tree…':treeLayout.save.status==='error'?'Tree not saved':treeLayout.save.version>0?'Tree saved':''} {Object.keys(filters).length?`${Object.keys(filters).length} active filters`:'All matching cells'}
{treeLayout.save.status==='error'&&
Tree layout could not be saved: {treeLayout.save.error}Retry tree save
}
{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel} }{tab==='inspect'?setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:PROTOCOL WORKSPACE
{humanize(data.definition?.name)} {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}
Go to epochs Refresh & compare setExportOpen(true)}> Export {initialRecipe&&
Reused saved query; matching the current catalog. Previous export unchanged.
}{diff&&
{diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)} <>{diff.candidate_revision_uuid&&onReviewCandidate(diff.candidate_revision_uuid,id)}>Review update }>setDiff(null)}> }{error&&tab!=='export'&&
{error}
}
Loading dataset summary… }>Filter results onRefine(data,filters)}> Search predicate filter('cell_type',e.target.value)}>All cell types {(data.filters?.cell_types || [...new Set((data.cells||[]).map(c=>c.cell_type))]).map(x=>{x} )} filter('group_label',e.target.value)}>All source groups {(data.filters?.group_labels||data.groups||[]).map(x=>{typeof x==='string'?x:x?.label || 'Not recorded'} )} {Object.keys(filters).length>0&&{setFilters({});setResult(null);}}>Clear filters }Group labels are original metadata, not inferred drug phases.
<>{suggestionsError&&New-data suggestions could not load. Retry suggestions
}{suggestion&&}Cells in this protocol openCell(null)}>Open inspection > }>};
}
-function ImportPage({projectId,protocols,sources,onChange,onBack,jobs,suggestions=[],suggestionsError,onRetrySuggestions,onReviewCandidate,onProtocol,queue}) {
+function ImportPage({projectId,protocols,sources,onChange,onBack,jobs,suggestions=[],approvedHistory=[],suggestionsError,onRetrySuggestions,onReviewCandidate,onProtocol,queue}) {
const jobRecords=Array.isArray(jobs.data?.jobs)?jobs.data.jobs:[];
const blocked=queue.active;
const error=queue.error;
- return ADD TO MAIN CATALOG
Import H5s Choose one or more H5 recordings. New imports are copied into this project and verified before parsing.
View data stores
{queue.busy&&
Importing one file at a time · {queue.remaining} waiting. Keep this app tab open until the queue finishes.
}{error&&
{error}
}{suggestionsError&&
New-data suggestions could not load. Retry suggestions
}
Import history {jobs.reload();onChange();}}> Refresh jobs {jobs.connectionError&&jobs.data&&Connection interrupted. Showing the last confirmed job history; use Refresh jobs to check again.
} ;
+ return ADD TO MAIN CATALOG
Import H5s Choose one or more H5 recordings. New imports are copied into this project and verified before parsing.
View data stores
{queue.busy&&
Importing one file at a time · {queue.remaining} waiting. Keep this app tab open until the queue finishes.
}{error&&
{error}
}{suggestionsError&&
New-data suggestions could not load. Retry suggestions
}
Import history {jobs.reload();onChange();}}> Refresh jobs {jobs.connectionError&&jobs.data&&Connection interrupted. Showing the last confirmed job history; use Refresh jobs to check again.
} ;
}
function HistoryPage({kind,revision,onReuse}){const [reuseError,setReuseError]=useState(''),[reusing,setReusing]=useState(null);async function reuseExport(exported){const id=exported.dataset_uuid || exported.export_uuid;setReusing(id);setReuseError('');try{const recipe=await api(`/exports/${id}/reuse`);onReuse(recipe);}catch(error){setReuseError(error.message);}finally{setReusing(null);}}const result=useResource(kind==='exports'?'/exports':'/events',revision);return PROJECT RECORD
{kind==='exports'?'Project export log':'Activity & logs'} {kind==='exports'?'Saved protocol and one-off search exports. Reuse opens the saved query for review; it never exports automatically.':'A persistent record of imports, tags, review decisions and exports.'}
Refresh {kind==='exports'&&
}{reuseError&&{reuseError}
}{kind==='exports'?{(result.data?.exports || []).length?(result.data.exports).map((e,i)=>
{e.format==='wheeler-sqlite'?
:}{e.name || e.filename || e.export_uuid} {e.export_scope?.kind==='explorer_candidate'?'One-off search':'Protocol export'} · {time(e.created_at || e.at)} · {number(e.epoch_count ?? e.membership?.length)} epochs · {exportFormatLabel(e.format)}
Export record & provenance {JSON.stringify(e,null,2)} {(e.dataset_uuid || e.export_uuid)&&reuseExport(e)}> {e.export_scope?.kind==='explorer_candidate'?'Review & export again':'Reuse query'} }{e.download_url&& {exportDownloadLabel(e.format)} } ):
Use Search predicate to export a matching selection directly, or export from a protocol workspace. }
: } ;}
export default function App(){
@@ -136,4 +136,4 @@ export default function App(){
function searchEpoch(result){if(result.protocol_uuid)openInspection(result.protocol_uuid,result);else openQC(result.cell_uuid,result.epoch_uuid);}
function searchPredicate(predicate){navigation.go('explore',{explorePredicate:predicate});}
function changed(){setRevision(x=>x+1);}
- const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return {setup}{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>} ;return setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject}/> setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject} error={projectError || registry.error} onRetry={registry.reload}/>setPage('overview')}> Project overview explore()}> Search predicate setPage('stores')}> Data stores
{data?.project?.project_uuid &&
}PROJECT RECORD
setFolderOpen(true)}> Project folder setPage('activity')}> Activity & logs setPage('files')}> Project files setPage('exports')}> Export log Main project catalog DataJoint · local source references
{hidden?:} setPage('overview')} title="Project overview">{projectName} / {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)} Search predicate openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace {reviewImportJob&&}setPage('import')}/>{folderOpen&&
setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&{setup}
}{openingProject&& Opening {openingProject}…
}{projectError&&{projectError}setProjectError('')} aria-label="Dismiss project error">
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:} Source files preserved · stable epoch identities Metadata first. Traces loaded on demand. ;}
+ const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return {setup}{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>} ;return setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject}/> setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject} error={projectError || registry.error} onRetry={registry.reload}/>setPage('overview')}> Project overview explore()}> Search predicate setPage('stores')}> Data stores
{data?.project?.project_uuid &&
}PROJECT RECORD
setFolderOpen(true)}> Project folder setPage('activity')}> Activity & logs setPage('files')}> Project files setPage('exports')}> Export log Main project catalog DataJoint · local source references
{hidden?:} setPage('overview')} title="Project overview">{projectName} / {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)} Search predicate openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace {reviewImportJob&&}setPage('import')}/>{folderOpen&&
setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&{setup}
}{openingProject&& Opening {openingProject}…
}{projectError&&{projectError}setProjectError('')} aria-label="Dismiss project error">
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} approvedHistory={suggestionsResource.data?.approved_history||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:} Source files preserved · stable epoch identities Metadata first. Traces loaded on demand. ;}
diff --git a/workspace-app/src/components/ProtocolSuggestion.css b/workspace-app/src/components/ProtocolSuggestion.css
index 67ff53e..e567b09 100644
--- a/workspace-app/src/components/ProtocolSuggestion.css
+++ b/workspace-app/src/components/ProtocolSuggestion.css
@@ -6,3 +6,50 @@
.import-compare-cards{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:18px 0 16px}.import-compare-card{border:1px solid #e3dce9;border-radius:10px;background:#fbf9fd;padding:13px 15px}.import-compare-card header{display:flex;align-items:center;gap:8px;color:#725887}.import-compare-card header strong{font-size:12px;flex:1}.import-compare-card header>span{font-size:11px;background:#e7f3ec;color:#386a50;border-radius:12px;padding:4px 7px}.import-compare-card header>span.is-removal{background:#faeaea;color:#a05b58}.import-before-after{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:15px}.import-before-after>div{min-width:0}.import-before-after>div:last-child{text-align:right}.import-before-after small{display:block;font-size:10px;color:#94839e;margin-bottom:4px}.import-before-after strong{font-size:26px;font-weight:600;letter-spacing:-.6px;color:#66546f}.import-before-after>div:last-child strong{color:#356d53}.import-before-after>svg{color:#a99ab4;flex-shrink:0}.import-membership-comparison{display:grid;gap:8px}.import-membership-comparison>div{display:grid;grid-template-columns:60px 1fr;gap:8px;align-items:center}.import-membership-comparison>div>span{font-size:10px;color:#8c7a98}.import-membership-bar i.removed,.import-membership-caption i.removed{background:#d7938d}.import-cell-preview{margin-top:17px;border-top:1px solid #eee8f2;padding-top:12px}.import-cell-preview h4{display:flex;align-items:center;gap:7px;font-size:11px;color:#81708e;margin:0 0 9px}.import-cell-preview-row{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid #f1edf4}.import-cell-preview-row>div{flex:1;min-width:0}.import-cell-preview-row strong{font-size:12px;color:#66536f}.import-cell-preview-row small{display:block;font-size:10px;color:#96869f;margin-top:3px}.import-cell-change-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:#eaf4ee;color:#4b8967;flex-shrink:0}.import-cell-preview-row.updated .import-cell-change-icon{background:#efe9f6;color:#8b6b9e}.import-cell-preview-row.removed .import-cell-change-icon{background:#f9eae8;color:#ac6b65}.import-cell-preview-row>span:last-child{display:flex;align-items:center;gap:6px;font-size:11px;color:#7b6988}.import-cell-preview>small{display:block;margin-top:9px;color:#998bA1;font-size:10px}.import-comparison-context{display:flex;flex-wrap:wrap;gap:13px;margin-top:14px;color:#93819e;font-size:10px}.import-comparison-context>span{display:flex;gap:5px;align-items:center}@media(max-width:540px){.import-compare-cards{grid-template-columns:1fr}.import-before-after strong{font-size:23px}.import-cell-preview-row{flex-wrap:wrap}}
.import-readiness{overflow:clip}.import-readiness-heading{position:sticky;top:0;z-index:2;padding:16px 24px}.import-heading-actions{display:flex;align-items:center;gap:12px;flex-shrink:0}.import-heading-actions>.primary{white-space:nowrap}.import-review-dialog>header{position:sticky;top:0;z-index:5;background:#fff;border-bottom:1px solid #e7e0ec}.import-review-dialog .import-readiness-heading{position:static}@media(max-width:650px){.import-heading-actions{gap:8px;flex-wrap:wrap}.import-ready-total{white-space:normal}.import-readiness-heading{align-items:flex-start}.import-review-dialog>header p{display:none}}
+
+/* Keep review content inside consistent gutters, including empty states/history. */
+.import-review-dialog{box-sizing:border-box;width:min(960px,calc(100vw - 48px));max-height:calc(100dvh - 48px);overflow-x:hidden;scrollbar-gutter:stable}
+.import-review-dialog .import-readiness{margin:20px;border:1px solid #e5dfea;border-radius:12px}
+.import-review-dialog>header{padding:22px 24px;flex-wrap:wrap}
+.import-review-dialog>header>div:first-child{flex:1;min-width:220px}
+.import-review-dialog>footer{position:sticky;bottom:0;background:var(--surface,#fff);z-index:4}
+.import-readiness-heading>div:first-child{min-width:0;flex:1}
+.import-readiness-heading h2{line-height:1.35;overflow-wrap:anywhere}
+.import-readiness-heading h2>svg{flex-shrink:0}
+.import-readiness-heading .import-heading-actions{flex-shrink:1;min-width:0}
+.import-ready-total{white-space:normal;line-height:1.45;text-align:center}
+.import-source-summary{flex-wrap:wrap;gap:12px 20px;padding:20px 24px;line-height:1.55}
+.import-source-summary>svg{flex-shrink:0}
+.import-source-summary>div{flex:1 1 220px;min-width:0;overflow-wrap:anywhere}
+.import-source-summary>span{flex:0 1 auto}
+.import-source-summary .import-checked{margin-left:auto}
+.import-review-empty{margin:20px 24px;padding:14px 16px;background:#f7f5f9;border-radius:8px;color:var(--ink-secondary);line-height:1.5}
+.import-approved-entry{margin:10px 0;border:1px solid #e5dfea;border-radius:9px;padding:0 14px;background:#fff}
+.import-approved-entry>summary{display:flex;align-items:center;gap:8px;cursor:pointer;padding:14px 0;font-size:12px;line-height:1.6;color:var(--ink-secondary);overflow-wrap:anywhere}
+.import-approved-entry>summary>svg{flex-shrink:0;color:#497554}
+.import-approved-entry[open]>summary{border-bottom:1px solid #eee8f2;margin-bottom:14px}
+.import-ready-protocol{padding:20px;margin-bottom:16px}
+.protocol-new-data-heading{flex-wrap:wrap;row-gap:10px}
+.protocol-new-data-heading>div{flex:1 1 160px}
+.import-ready-state{flex-shrink:0}
+.import-compare-card{min-width:0;padding:16px}
+.import-compare-card header{flex-wrap:wrap}
+.import-compare-card header>span{margin-left:auto}
+.import-before-after{gap:10px}
+.import-before-after strong{font-size:clamp(20px,3vw,26px);overflow-wrap:anywhere}
+.import-before-after small{line-height:1.5}
+.import-page .page-heading,.data-stores .page-heading{flex-wrap:wrap;gap:20px}
+.import-page .page-heading>div:first-child,.data-stores .page-heading>div:first-child{flex:1 1 340px;min-width:0}
+.ds-heading-actions{flex-wrap:wrap;justify-content:flex-end;max-width:100%}
+@media(max-width:650px){
+ .import-review-dialog{width:calc(100vw - 24px);max-height:calc(100dvh - 24px)}
+ .import-review-dialog .import-readiness{margin:12px}
+ .import-review-dialog>header,.import-review-dialog>footer{padding:16px}
+ .import-readiness-heading{flex-direction:column;padding:18px}
+ .import-source-summary{padding:18px}
+ .import-priority-group,.import-other-protocols,.import-review-empty{margin:18px}
+ .import-approved-entry{padding:0 10px}
+ .import-ready-protocol{padding:16px}
+ .import-compare-cards{grid-template-columns:1fr}
+ .ds-heading-actions{justify-content:flex-start}
+}
diff --git a/workspace-app/src/components/ProtocolSuggestion.jsx b/workspace-app/src/components/ProtocolSuggestion.jsx
index 8c364cf..32f7fa0 100644
--- a/workspace-app/src/components/ProtocolSuggestion.jsx
+++ b/workspace-app/src/components/ProtocolSuggestion.jsx
@@ -8,7 +8,7 @@ import './ProtocolSuggestion.css';
import {datedCellLabel} from '../recordingIdentity.js';
import {importReadiness,suggestionDates} from '../importReadiness.js';
-export default function ProtocolSuggestion({suggestion,onChange,onReview,onProtocol,importView=false,disabled=false,approvalSignal=0,onBusyChange}){
+export default function ProtocolSuggestion({suggestion,onChange,onReview,onProtocol,importView=false,disabled=false,approvalSignal=0,onBusyChange,onApproved}){
const [comparison,setComparison]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[applied,setApplied]=useState(false);
const lastSignal=useRef(approvalSignal);
useEffect(()=>{if(approvalSignal!==lastSignal.current){lastSignal.current=approvalSignal;if(approvalSignal>0)void add();}},[approvalSignal]);
@@ -21,7 +21,7 @@ export default function ProtocolSuggestion({suggestion,onChange,onReview,onProto
try{
const result=await approveProtocolSuggestion(suggestion,shown,api);
if(!result.applied){setComparison(result.comparison);setMessage('The dataset or inspection state changed. Review this refreshed comparison, then approve it individually.');return;}
- setApplied(true);setMessage('Approved. The saved dataset in this sidebar protocol is updated. Open it to inspect the new epochs; no export file was created.');onChange?.();
+ setApplied(true);onApproved?.(suggestion.candidate_revision_uuid);setMessage('Approved. The saved dataset in this sidebar protocol is updated. Open it to inspect the new epochs; no export file was created.');onChange?.();
}catch(error){setError(error.message);}finally{setBusy(false);onBusyChange?.(false);}
}
const removing=(shown.diff_counts?.removed || 0)>0;
@@ -48,25 +48,32 @@ function ReadinessVisual({suggestion,applied=false}){
}
function readPins(projectId){try{return JSON.parse(localStorage.getItem(`rieke-os.sidebar.protocols.v1.${projectId}`))||{};}catch{return {};}}
-export function ImportSuggestions({suggestions=[],protocols=[],projectId,sources=[],jobs=[],onReview,onProtocol,onChange,autoOpenJobUuid=null,dialogOnly=false,reviewLoading=false}){
+export function ImportSuggestions({suggestions=[],approvedHistory=[],protocols=[],projectId,sources=[],jobs=[],onReview,onProtocol,onChange,autoOpenJobUuid=null,dialogOnly=false,reviewLoading=false}){
const [preferences,setPreferences]=useState(()=>readPins(projectId));
const [approvalBatch,setApprovalBatch]=useState({sequence:0,revisions:[]}),[busyCount,setBusyCount]=useState(0),[open,setOpen]=useState(false);
- const dialogRef=useRef(null);
+ const dialogRef=useRef(null),reviewHadPending=useRef(false);
+ const [approvedHere,setApprovedHere]=useState([]);
const busy=busyCount>0;
function busyChanged(value){setBusyCount(count=>Math.max(0,count+(value?1:-1)));}
useEffect(()=>{const update=()=>setPreferences(readPins(projectId));update();window.addEventListener('storage',update);window.addEventListener('rieke-protocol-shortcuts-changed',update);return()=>{window.removeEventListener('storage',update);window.removeEventListener('rieke-protocol-shortcuts-changed',update);};},[projectId]);
- const model=importReadiness({suggestions,protocols,preferences});
+ const current=suggestions.map(item=>approvedHere.includes(item.candidate_revision_uuid)?{...item,status:'applied'}:item);
+ const model=importReadiness({suggestions:current.filter(item=>item.status!=='applied'),protocols,preferences});
+ const past=[...new Map([...current.filter(item=>item.status==='applied'),...approvedHistory].map(item=>[item.candidate_revision_uuid,item])).values()];
+ const reviewPending=model.ready>0||model.stale>0;
+ useEffect(()=>{if(!open){reviewHadPending.current=false;return;}if(reviewPending)reviewHadPending.current=true;else if(reviewHadPending.current&&!busy&&!reviewLoading)setOpen(false);},[open,reviewPending,busy,reviewLoading]);
const latest=(autoOpenJobUuid&&jobs.find(job=>job.job_uuid===autoOpenJobUuid))||[...jobs].filter(job=>job&&(['complete','completed','success'].includes(job.status)||(job.status==='complete_with_warnings'&&job.catalog_committed===true))).sort((a,b)=>String(b.finished_at).localeCompare(String(a.finished_at)))[0];
const catalogDelta=latest?.catalog_delta||latest?.progress?.catalog_delta;
const source=sources.find(item=>item.source_sha256===latest?.source_sha256)||(latest?{filename:latest.source?.split?.('/').pop()||latest.filename||'Imported recording',counts:latest.progress?.counts}:null);
useEffect(()=>{if(autoOpenJobUuid)setOpen(true);},[autoOpenJobUuid]);
useEffect(()=>{if(open&&!dialogRef.current?.open)dialogRef.current?.showModal();else if(!open&&dialogRef.current?.open)dialogRef.current.close();},[open,latest?.job_uuid,suggestions.length]);
if(!suggestions.length&&!latest)return null;
- const render=item=>{setOpen(false);onProtocol?.(id);}} onReview={(...args)=>{setOpen(false);onReview?.(...args);}} importView disabled={busy||reviewLoading} approvalSignal={approvalBatch.revisions.includes(item.candidate_revision_uuid)?approvalBatch.sequence:0} onBusyChange={busyChanged}/>;
+ const render=item=>{setOpen(false);onProtocol?.(id);}} onReview={(...args)=>{setOpen(false);onReview?.(...args);}} importView disabled={busy||reviewLoading} approvalSignal={approvalBatch.revisions.includes(item.candidate_revision_uuid)?approvalBatch.sequence:0} onBusyChange={busyChanged} onApproved={id=>setApprovedHere(previous=>previous.includes(id)?previous:[...previous,id])}/>;
const approveAll=setApprovalBatch(value=>({sequence:value.sequence+1,revisions:model.pinnedPending.map(item=>item.candidate_revision_uuid)}))}>{busy? :} {busy?'Checking updates…':'Approve All'} ;
const summary=IMPORT REVIEW
{model.pinnedReady>0?'Review your pinned protocol updates.':'Protocol matching summary.'} Review the catalog import, then approve matching data for your pinned protocols.
{!open&&approveAll}{model.pinnedReady} pinned updates pending{model.added>0?` · ${model.added} approved`:''}{model.stale>0?` · ${model.stale} need refresh`:''}
{source&&{source.filename} Latest completed import · {time(latest.finished_at)}
{number(source.counts?.cells)} cells in imported source{number(source.counts?.epochs)} epochs in imported source Imported }{catalogDelta&& Added to the overall data store {[['sources_added','recordings'],['cells_added','cells'],['epochs_added','epochs'],['protocol_types_added','protocol types']].filter(([key])=>Number.isInteger(catalogDelta[key])).map(([key,label])=>+{number(catalogDelta[key])} {label} )}
New catalog records from this import. Protocol updates below are approved separately.
}{latest?.recording_storage?.verified&&latest?.recording_storage?.original_removal_safe&& Saved a verified copy in this project. You can remove the original H5 from Downloads; keep the project copy.
}{latest?.warnings?.length>0&&Some post-import checks need attention. See import diagnostics below.
}
- {reviewLoading&& Refreshing catalog totals and protocol matches…
}{model.pinned.length>0&& Pinned protocols · {model.pinnedReady} pending {model.pinned.map(({protocol,suggestion})=>suggestion?render(suggestion):
{humanize(protocol.name)} No saved comparison is available. Open the protocol and Refresh & compare to check for updates.
onProtocol(protocol.protocol_uuid)}>Open protocol )}
}
- {model.other.length>0&&row.suggestion?.status==='pending')}>{model.pinned.length?'Other saved protocols':'Saved protocol matches'} {model.other.length} {model.other.map(render)} }
+ {reviewLoading&& Refreshing catalog totals and protocol matches…
}{model.pinned.some(row=>row.suggestion)&& Pinned protocols · {model.pinnedReady} pending {model.pinned.filter(row=>row.suggestion).map(row=>render(row.suggestion))}
}
+ {model.other.length>0&&
Other pending updates · {model.other.length} {model.other.map(render)}}
+ {!model.ready&&!model.stale&&No unapproved protocol updates.
}
+ {past.length>0&&Approved updates {past.length} {past.map(item=> {humanize(item.protocol_name)} · {item.source_filename} · Approved {time(item.approved_at||item.created_at)} {render(item)} )} }
Approval updates the existing sidebar protocol’s saved dataset and records its query and changes. It does not create or replace an export file. Open the protocol to inspect and tag epochs. Cells may appear in more than one protocol.
;
- return <>{!dialogOnly&&{latest?'Import complete · review added data and protocol matches':'Protocol updates ready for review'} setOpen(true)}>Review import
}{!dialogOnly&&!open&&summary}{if(busy)event.preventDefault();else setOpen(false);}} onClose={()=>setOpen(false)} aria-labelledby="import-review-title">{open&&summary}setOpen(false)}>Done reviewing >;
+ return <>{!dialogOnly&&{reviewPending?'Protocol updates ready for review':latest?'Import complete · review saved':'Import review'} setOpen(true)}>Review import
}{if(busy)event.preventDefault();else setOpen(false);}} onClose={()=>setOpen(false)} aria-labelledby="import-review-title">{open&&summary}setOpen(false)}>Done reviewing >;
}