From 0ac02741b637c00ca61be206008a311a49b47512 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:27:05 -0700 Subject: [PATCH 1/8] Use one export entry point with visual destination choices --- .../src/components/EpochDetailHeading.jsx | 5 ++- .../src/components/EpochExportButton.jsx | 35 ------------------- .../src/components/ExportSelectionDialog.jsx | 4 +-- workspace-app/src/components/Inspector.jsx | 2 +- .../src/components/MatchingEpochs.jsx | 2 +- .../src/components/MetadataExplorer.jsx | 10 +++--- workspace-app/src/epochExport.js | 11 ------ workspace-app/src/epochExport.test.js | 17 --------- 8 files changed, 11 insertions(+), 75 deletions(-) delete mode 100644 workspace-app/src/components/EpochExportButton.jsx delete mode 100644 workspace-app/src/epochExport.js delete mode 100644 workspace-app/src/epochExport.test.js diff --git a/workspace-app/src/components/EpochDetailHeading.jsx b/workspace-app/src/components/EpochDetailHeading.jsx index 5aba9db..4260a5a 100644 --- a/workspace-app/src/components/EpochDetailHeading.jsx +++ b/workspace-app/src/components/EpochDetailHeading.jsx @@ -1,8 +1,7 @@ import {humanize} from '../api.js'; import {datedCellLabel} from '../recordingIdentity.js'; import {Badge} from './Common.jsx'; -import EpochExportButton from './EpochExportButton.jsx'; -export default function EpochDetailHeading({epoch,onQC,disabled=false,...exportScope}){ - return

{datedCellLabel(epoch)}

{humanize(epoch.protocol_name?.split('.').at(-1))||'Protocol not recorded'} · Epoch {epoch.epoch_number??'—'} within block · {epoch.start_time?.split(/[T ]/)[1]?.slice(0,8)||'Time not recorded'}

{onQC&&}{epoch.curation?.included===false?'Excluded':'Included'}{epoch.exports?.length?`${epoch.exports.length} saved exports`:'No saved export'}
; +export default function EpochDetailHeading({epoch,onQC,disabled=false}){ + return

{datedCellLabel(epoch)}

{humanize(epoch.protocol_name?.split('.').at(-1))||'Protocol not recorded'} · Epoch {epoch.epoch_number??'—'} within block · {epoch.start_time?.split(/[T ]/)[1]?.slice(0,8)||'Time not recorded'}

{onQC&&}{epoch.curation?.included===false?'Excluded':'Included'}{epoch.exports?.length?`${epoch.exports.length} saved exports`:'No saved export'}
; } diff --git a/workspace-app/src/components/EpochExportButton.jsx b/workspace-app/src/components/EpochExportButton.jsx deleted file mode 100644 index 7228925..0000000 --- a/workspace-app/src/components/EpochExportButton.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import {useEffect,useRef,useState} from 'react'; -import {createPortal} from 'react-dom'; -import {Download,X} from 'lucide-react'; -import {api} from '../api.js'; -import {epochExportOptions,epochExportPredicate} from '../epochExport.js'; -import './ExportSelectionDialog.css'; - -function EpochExportDialog({epoch,protocolId,queryRevision,filters,splits,predicate,onClose,onExported}){ - const dialog=useRef(null),inFlight=useRef(false); - const [format,setFormat]=useState('wheeler-sqlite'),[busy,setBusy]=useState(false),[error,setError]=useState(''),[result,setResult]=useState(null); - useEffect(()=>{dialog.current.showModal();return()=>dialog.current?.close();},[]); - async function run(){ - if(inFlight.current)return; - inFlight.current=true;setBusy(true);setError('');setResult(null); - try{ - let receipt; - if(protocolId){ - receipt=await api(`/protocols/${protocolId}/exports`,{method:'POST',body:epochExportOptions(epoch,format,{filters,splits,queryRevision})}); - }else{ - const candidate=await api('/explore/revisions',{method:'POST',body:{predicate:epochExportPredicate(predicate,epoch.epoch_uuid),splits,summary_only:true,name:`${epoch.cell_label||'Recording'} · epoch ${epoch.epoch_number??epoch.epoch_uuid}`}}); - if(candidate.recipe.epoch_count!==1)throw new Error('This epoch no longer matches the search. Refresh the results before exporting.'); - receipt=await api(`/explore/revisions/${candidate.revision_uuid}/exports`,{method:'POST',body:{format,expected_recipe_sha256:candidate.recipe.full_recipe_sha256}}); - } - if(receipt.epoch_count!==1||typeof receipt.download_url!=='string'||!receipt.download_url.startsWith('/api/exports/')||typeof receipt.dataset_uuid!=='string')throw new Error('The server did not return a complete export receipt. Check Activity & logs before retrying.'); - setResult(receipt);onExported?.(receipt); - }catch(failure){setError(failure.message);}finally{inFlight.current=false;setBusy(false);} - } - return createPortal({event.preventDefault();if(!busy)onClose();}}>

Export epoch

Export only this epoch, with its recording metadata and linked H5 source.

{error&&

{error}

}{result&&

1 epoch exported · Download {format==='epictree-mat'?'MATLAB bundle':'SQLite database'}

}
,document.body); -} - -export default function EpochExportButton({epoch,disabled=false,...scope}){ - const [open,setOpen]=useState(false); - const excluded=!!scope.protocolId&&epoch?.curation?.included===false; - return <>{open&&setOpen(false)}/>}; -} diff --git a/workspace-app/src/components/ExportSelectionDialog.jsx b/workspace-app/src/components/ExportSelectionDialog.jsx index 097e8de..c38ca62 100644 --- a/workspace-app/src/components/ExportSelectionDialog.jsx +++ b/workspace-app/src/components/ExportSelectionDialog.jsx @@ -1,5 +1,5 @@ import {useEffect,useRef,useState} from 'react'; -import {Download,Pin,X} from 'lucide-react'; +import {Download,Pin,FolderPlus,X} from 'lucide-react'; import CandidateExportPanel from './CandidateExportPanel.jsx'; import ProtocolApplyPanel from './ProtocolApplyPanel.jsx'; import NewPinnedProtocol from './NewPinnedProtocol.jsx'; @@ -22,7 +22,7 @@ export default function ExportSelectionDialog({candidate,protocols,projectId,ini } return {event.preventDefault();if(!busy)onClose();}}>

Export selection

- +
{!mode&&

Choose a destination for these matching epochs.

} {mode==='direct'&&} diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index ecd1069..0ed3cda 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -323,7 +323,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi {!metadataOpen&&{tagEntry}} } detail={designMode?curate({included},'focused',item.epoch_uuid)} cells={protocol.cells} selectedEpochs={targets} setSelectedEpochs={selectOverviewTargets} selectedCell={cellTagRequest?.cell_uuid} onSelectCell={(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});}} presentation="columns" protocolId={id} filters={filters} splits={splits} revision={revision} design selected={focused} initialNavigation={designNavigation} onNavigationChange={setDesignNavigation} onMetadata={receiveTree} onStatus={setTreeStatus} onSelectEpoch={(uuid,info)=>{focusTreeEpoch(uuid,info);setTreeMode(false);setDesignMode(false);}}/>:{targets.length>0?:focusedEpoch?<> - {epoch.reload();onChange();}} onQC={onQC} disabled={busy} protocolId={id} queryRevision={protocol.query_revision} filters={filters} splits={splits}/> + diff --git a/workspace-app/src/components/MatchingEpochs.jsx b/workspace-app/src/components/MatchingEpochs.jsx index b07d5de..3da99e7 100644 --- a/workspace-app/src/components/MatchingEpochs.jsx +++ b/workspace-app/src/components/MatchingEpochs.jsx @@ -77,7 +77,7 @@ function MatchingEpochsContent({toolbarTarget=null,onFilter,filterDisabled=false listProps={{cells:cells||[],source,revision:preview.tree_revision,focused:cellTagRequest?null:focused,onFocus:selectEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets,disabled:!!page.error}} childrenInTree> {!metadataOpen&&{tagEntry}} } - detail={{targets.length>0?:detail?<>{!metadataOpen&&!treeOpen&&tagEntry}:Select an epoch from the tree to inspect its response and metadata.}} + detail={{targets.length>0?:detail?<>{!metadataOpen&&!treeOpen&&tagEntry}:Select an epoch from the tree to inspect its response and metadata.}} metadata={setTargets([])} tags={tagEntry} epoch={detail} catalog={{data:preview.catalog}} onClose={()=>toggleMetadata(false)} connections={detail&&}/>}/> ; diff --git a/workspace-app/src/components/MetadataExplorer.jsx b/workspace-app/src/components/MetadataExplorer.jsx index 643a82f..5dc9297 100644 --- a/workspace-app/src/components/MetadataExplorer.jsx +++ b/workspace-app/src/components/MetadataExplorer.jsx @@ -119,7 +119,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, try{const data=await api('/explore/preview',{method:'POST',body:{predicate,splits:grouping,summary_only:true,...(baseline?{baseline_revision_uuid:baseline}:{})},signal:controller.signal});if(!controller.signal.aborted){setDraftPreview({data,loading:false,error:null,key});setAnnotationHold(null);if(openResults){setResultsFromDraft(true);setStep('results');setDestination(null);setFocused(null);}}} catch(error){if(!controller.signal.aborted)setDraftPreview({data:null,loading:false,error:error.message,key});} } - async function saveRevision(kind,nextStep='results'){ + async function saveRevision(kind,nextStep='results',notifyParent=true){ if(annotationHold){setError('Refresh results after editing annotations before recording or exporting this selection.');return;} const predicate=kind==='filter'?compiled.predicate:applied?.recipe.predicate; if(!predicate||busy)return; @@ -130,7 +130,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, if(parent)body.parent_revision_uuid=parent; const result=await api('/explore/revisions',{method:'POST',body}); if(!result.recipe||!result.revision_uuid)throw new Error('The server did not return a recorded revision. Reload history before retrying.'); - setApplied({...result,previewGeneration:generation,previewRevision:viewRevision});if(result.preview)setTree({data:result.preview,loading:false,error:null});onChange?.();setSplits(result.recipe.splits);setFilterSplits(result.recipe.splits);setPath([]);setTreeNavigation(null);setFocused(null);setStep(nextStep);setResultsFromDraft(false); + setApplied({...result,previewGeneration:generation,previewRevision:viewRevision});if(result.preview)setTree({data:result.preview,loading:false,error:null});if(notifyParent)onChange?.();setSplits(result.recipe.splits);setFilterSplits(result.recipe.splits);setPath([]);setTreeNavigation(null);setFocused(null);setStep(nextStep);setResultsFromDraft(false); if(kind==='filter'){setDraft(draftOf(result.recipe.predicate));setRestored(null);} storePreset({name:body.name,predicate,splits:result.recipe.splits,matched_count:result.summary?.matched_count??result.preview?.matched_count}); setHistoryVersion(value=>value+1);setHistoryOffset(0);setNotice(`Recorded revision ${result.revision_uuid.slice(0,8)}. Its query and exact epoch membership are preserved.`);return result; @@ -188,7 +188,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, } async function prepareDestination(next,fromDraft=resultsFromDraft){ if(annotationHold){setError('Refresh results after editing annotations before using this selection.');return;} - if(fromDraft||!applied||changedTree||changedMembership){const result=await saveRevision(fromDraft?'filter':'tree',next==='tree'?'tree':'results');if(!result)return;} + if(fromDraft||!applied||changedTree||changedMembership){const result=await saveRevision(fromDraft?'filter':'tree',next==='tree'?'tree':'results',next!=='export');if(!result)return;} if(next==='tree'){setStep('tree');setDestination(null);}else setDestination(next); } const previousOpenRequest=useRef(openRequest); @@ -240,7 +240,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, {annotationHold&&
Annotations changed. The current epoch page and selection are retained; refresh results before exporting or applying this query.
} {notice&&
{notice}
} {initialCandidateLoading?
Loading saved candidate…
:initialRevisionId&&!applied&&!departedInitial?
The saved candidate could not be loaded.
:step==='filter'&&!focused?usePreset(item,'edit')} onPin={item=>usePreset(item,'pin')}/>:step==='results'?
- {destination&&applied&&!resultsFromDraft&&setDestination(null)} onApplied={onProtocolApplied} onChanged={()=>onChange?.()}/>} + {destination&&applied&&!resultsFromDraft&&{setDestination(null);onChange?.();}} onApplied={onProtocolApplied} onChanged={()=>onChange?.()}/>} {resultError&&
{resultError}
} {!resultLoading&&!resultError&&resultPreview&&resultPredicate?{if(!annotationHold&&!busy&&!resultLoading&&!resultError)prepareDestination('tree');}} key={resultPreview.tree_revision} predicate={resultPredicate} splits={resultSplits} preview={resultPreview} session={matchingNavigation} onSession={setMatchingNavigation} onAnnotationsChanged={annotationsChanged} onTagFilter={predicate=>{setDraft(draftOf(predicate));setActivePreset(null);setEditorOpen(true);}} onRefresh={refreshResults}/>:}
:<> @@ -248,7 +248,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, {changedFilter&&Unapplied filter edits}{changedTree||changedMembership?<>{changedMembership?'Current preview differs from saved revision':'Tree changes are a draft'}:Filter & tree recorded} {treeDiff&&(treeDiff.added||treeDiff.removed||treeDiff.changed)?Current data: +{treeDiff.added} / −{treeDiff.removed} / {treeDiff.changed} changed:null}
- {!focused&&applied&&
Tree layout
} + {!focused&&applied&&
Tree layout
} {!focused&&applied&&} {if(name==='tree')setGroupingWidth(value);}} diff --git a/workspace-app/src/epochExport.js b/workspace-app/src/epochExport.js deleted file mode 100644 index 8beac1d..0000000 --- a/workspace-app/src/epochExport.js +++ /dev/null @@ -1,11 +0,0 @@ -// Scope a one-off export by immutable identity, never by an epoch's block number. -export function epochExportPredicate(predicate,epochUuid){ - if(!epochUuid)throw new Error('Choose an epoch before exporting'); - return {all:[predicate,{field:'epoch',operator:'eq',value:epochUuid}]}; -} -export function epochExportOptions(epoch,format,{filters={},splits='date,cell,block',queryRevision}={}){ - if(!epoch?.epoch_uuid)throw new Error('Choose an epoch before exporting'); - if(epoch.curation?.included===false)throw new Error('Include this epoch before exporting it from this protocol'); - return {format,query_revision:queryRevision,filters:{...filters,epoch_uuid:epoch.epoch_uuid},split_order:splits, - review_policy:'include_unreviewed',name:`${epoch.cell_label||'Recording'} · epoch ${epoch.epoch_number??epoch.epoch_uuid}`}; -} diff --git a/workspace-app/src/epochExport.test.js b/workspace-app/src/epochExport.test.js deleted file mode 100644 index 0d7efe4..0000000 --- a/workspace-app/src/epochExport.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {epochExportOptions,epochExportPredicate} from './epochExport.js'; -test('focused export uses UUID and preserves local filters, not repeated block numbers',()=>{ - const epoch={epoch_uuid:'unique',epoch_number:1,cell_label:'Cell1',curation:{included:true}}; - const filters={tag:'reviewed',cell_uuid:'cell'}; - const options=epochExportOptions(epoch,'wheeler-sqlite',{filters,splits:'cell',queryRevision:'revision'}); - assert.deepEqual(options.filters,{...filters,epoch_uuid:'unique'}); - assert.deepEqual(filters,{tag:'reviewed',cell_uuid:'cell'}); - assert.equal(options.query_revision,'revision'); - assert.equal(options.split_order,'cell'); - assert.throws(()=>epochExportOptions({...epoch,curation:{included:false}},'wheeler-sqlite'),/Include this epoch/); -}); -test('focused search export intersects identity with the original predicate',()=>{ - const predicate={field:'protocol',operator:'contains',value:'Noise'}; - assert.deepEqual(epochExportPredicate(predicate,'unique'),{all:[predicate,{field:'epoch',operator:'eq',value:'unique'}]}); -}); From 2f1192b29119b4c53cdd87185ca302e634ba2b84 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:52:54 -0700 Subject: [PATCH 2/8] Render search and pinned results through one epoch viewer --- .../tests/test_workspace_candidate_exports.py | 26 +++++ .../tests/test_workspace_protocol_binding.py | 18 +++ workspace-app/src/App.jsx | 2 +- .../src/components/CandidateExportPanel.jsx | 2 +- workspace-app/src/components/ColumnTree.jsx | 5 +- .../src/components/EpochAnalysisInclusion.jsx | 4 + .../src/components/EpochBrowserChrome.jsx | 4 +- workspace-app/src/components/EpochViewer.jsx | 33 ++++++ .../src/components/HierarchyTree.jsx | 2 +- .../src/components/InspectionCellTree.jsx | 6 +- workspace-app/src/components/Inspector.css | 25 ++-- workspace-app/src/components/Inspector.jsx | 59 +++------- .../src/components/MatchingEpochs.jsx | 38 ++---- .../src/components/MetadataExplorer.css | 2 + .../src/components/MetadataExplorer.jsx | 108 ++++++++++-------- .../src/components/ProtocolViewFilter.jsx | 2 +- .../src/epochViewerArchitecture.test.js | 23 ++++ workspace-app/src/protocolViewFilter.js | 10 ++ workspace-app/src/protocolViewFilter.test.js | 12 +- workspace-app/src/searchInclusion.js | 29 +++++ workspace-app/src/searchInclusion.test.js | 39 +++++++ 21 files changed, 303 insertions(+), 146 deletions(-) create mode 100644 workspace-app/src/components/EpochAnalysisInclusion.jsx create mode 100644 workspace-app/src/components/EpochViewer.jsx create mode 100644 workspace-app/src/epochViewerArchitecture.test.js create mode 100644 workspace-app/src/searchInclusion.js create mode 100644 workspace-app/src/searchInclusion.test.js diff --git a/python/tests/test_workspace_candidate_exports.py b/python/tests/test_workspace_candidate_exports.py index 31ced0c..1df16f1 100644 --- a/python/tests/test_workspace_candidate_exports.py +++ b/python/tests/test_workspace_candidate_exports.py @@ -56,6 +56,32 @@ def export(self,candidate,format='reference-json',**options): return self.client.post('/api/explore/revisions/'+candidate['revision_uuid']+'/exports',json={ 'format':format,'expected_recipe_sha256':candidate['recipe']['full_recipe_sha256'],**options},headers=self.headers) + def test_local_search_exclusion_preserves_visible_query_and_exports_included_uuid(self): + base = {'field':'protocol','operator':'eq','value':'example'} + before = copy.deepcopy((self.case.curation.rows,self.case.protocol_bindings.rows)) + visible = self.save(base) + self.assertEqual(visible['recipe']['epoch_count'],2) + excluded, included = self.service.ids + candidate = self.save({'all':[base,{'field':'epoch','operator':'not_in','value':[excluded]}]}) + self.assertEqual(candidate['recipe']['epoch_count'],1) + response = self.export(candidate,'wheeler-sqlite') + self.assertEqual(response.status_code,201,response.get_json()) + saved = self.store.get_dataset_revision(response.get_json()['dataset_uuid']) + self.assertEqual([row['uuid'] for row in saved['recipe']['epochs']],[included]) + self.assertEqual(self.case.explorer_history.get(visible['revision_uuid'])['recipe']['predicate'],base) + self.assertEqual((self.case.curation.rows,self.case.protocol_bindings.rows),before) + + def test_many_local_exclusions_in_bounded_literals_preserve_exact_membership(self): + excluded = [self.service.ids[0]] + [str(uuid.uuid4()) for _ in range(250)] + clauses = [{'field':'epoch','operator':'not_in','value':excluded[offset:offset+64]} + for offset in range(0,len(excluded),64)] + candidate = self.save({'all':[{'field':'protocol','operator':'eq','value':'example'},*clauses]}) + self.assertEqual(candidate['recipe']['epoch_count'],1) + response = self.export(candidate,'wheeler-sqlite') + self.assertEqual(response.status_code,201,response.get_json()) + saved = self.store.get_dataset_revision(response.get_json()['dataset_uuid']) + self.assertEqual([row['uuid'] for row in saved['recipe']['epochs']],self.service.ids[1:]) + def test_focused_search_epoch_uuid_intersects_query_and_exports_one_row(self): key = self.service.ids[1] # Both rows have the same block epoch number. candidate = self.save({'all': [ diff --git a/python/tests/test_workspace_protocol_binding.py b/python/tests/test_workspace_protocol_binding.py index 10754ef..3076a17 100644 --- a/python/tests/test_workspace_protocol_binding.py +++ b/python/tests/test_workspace_protocol_binding.py @@ -179,6 +179,24 @@ def create_protocol(self,candidate,**overrides): 'expected_recipe_sha256':candidate['recipe']['content_sha256'],**overrides} return self.client.post('/api/explore/revisions/'+candidate['revision_uuid']+'/create-protocol',json=body,headers=self.headers) + def test_search_local_exclusion_is_identical_for_updated_and_new_protocols(self): + base = {'field':'protocol','operator':'eq','value':'example'} + original = self.save_candidate(base) + candidate = self.save_candidate({'all':[base,{'field':'epoch','operator':'not_in','value':self.service.ids[:1]}]}) + included = self.service.ids[1:] + self.assertEqual(candidate['recipe']['epoch_count'],1) + result = self.apply_candidate(candidate) + self.assertEqual(result.status_code,200,result.get_json()) + updated = self.client.get(self.base+'/epochs').get_json()['epochs'] + self.assertEqual([row['epoch_uuid'] for row in updated],included) + created = self.create_protocol(candidate) + self.assertEqual(created.status_code,201,created.get_json()) + new_rows = self.client.get('/api/protocols/'+created.get_json()['protocol_uuid']+'/epochs').get_json()['epochs'] + self.assertEqual([row['epoch_uuid'] for row in new_rows],included) + self.assertEqual(self.explorer_history.get(original['revision_uuid'])['recipe']['epoch_count'],2) + self.assertEqual(self.explorer_history.get(original['revision_uuid'])['recipe']['predicate'],base) + self.assertFalse(self.curation.rows) + def test_create_pinned_protocol_is_persistent_exact_and_retry_safe(self): candidate=self.narrow(); old=copy.deepcopy(self.service.protocols[self.service.protocol_id]) result=self.create_protocol(candidate) diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx index ae1223e..bff3fbd 100644 --- a/workspace-app/src/App.jsx +++ b/workspace-app/src/App.jsx @@ -138,4 +138,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
/ {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)}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}
}{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}/>:}
;} + 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
/ {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)}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}
}{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}/>:}
;} diff --git a/workspace-app/src/components/CandidateExportPanel.jsx b/workspace-app/src/components/CandidateExportPanel.jsx index 453631a..d0d5fb3 100644 --- a/workspace-app/src/components/CandidateExportPanel.jsx +++ b/workspace-app/src/components/CandidateExportPanel.jsx @@ -31,7 +31,7 @@ export default function CandidateExportPanel({candidate,onExported,disabled=fals } return
Export this result{number(count)} epochs · one-off export
-

Export the saved query and tree directly. All matching epochs are included; protocol masks and tags are not merged. Any explicitly queried tags stay in the query evidence.

+

Export this selection’s included epochs and saved tree. Existing protocol masks and tags are not merged. Any explicitly queried tags stay in the query evidence.

Handoff format diff --git a/workspace-app/src/components/ColumnTree.jsx b/workspace-app/src/components/ColumnTree.jsx index 6fbaada..91469b7 100644 --- a/workspace-app/src/components/ColumnTree.jsx +++ b/workspace-app/src/components/ColumnTree.jsx @@ -1,3 +1,4 @@ +import EpochInclusionToggle from './EpochInclusionToggle.jsx'; import {revealWithin} from '../epochListScroll.js'; import {useCallback,useEffect,useRef,useState} from 'react'; import {ArrowLeft,ArrowRight,ChevronRight,FolderOpen,Home,Activity,GitBranch} from 'lucide-react'; @@ -91,11 +92,11 @@ export default function ColumnTree(props){ {state.error&&
{state.error}
}
{state.columns.map((page,depth)=>{ - const terminal=page.kind==='epochs',entries=terminal?page.epochs:page.branches,field=page.levels?.[depth],combined=entries.some(item=>item.components?.length),blocked=state.loading||!!state.error; + const terminal=page.kind==='epochs',entries=terminal?page.epochs.map(item=>props.inclusionForEpoch?props.inclusionForEpoch(item):item):page.branches,field=page.levels?.[depth],combined=entries.some(item=>item.components?.length),blocked=state.loading||!!state.error; return
{terminal?:depth+1}
{terminal?'Inspect epochs':readableField(field?.label,field?.field)}{number(page.total)} {terminal?'epochs':'groups'} · {number(page.selection?.count??page.total_epochs)} epochs in scope
{if(element)panes.current.set(depth,element);else panes.current.delete(depth);}} onScroll={remember}> - {entries.map((item,index)=>terminal?:{props.onToggleInclusion&&}
:{loading?'Loading epoch…':position<0?'Focused epoch is outside the loaded page':`Epoch ${number(position+1)} of ${number(total)} matching epochs`}
; } export function EpochListHeading({treeMode=false,onTreeMode,onDesign,designDisabled=false,onCollapse}){ - return
{onCollapse&&}
{onTreeMode&&<>}
{onDesign&&}
; + return
{onCollapse&&}
{onTreeMode&&<>}
{onDesign&&}
; } diff --git a/workspace-app/src/components/EpochViewer.jsx b/workspace-app/src/components/EpochViewer.jsx new file mode 100644 index 0000000..95c0472 --- /dev/null +++ b/workspace-app/src/components/EpochViewer.jsx @@ -0,0 +1,33 @@ +import {EpochBrowserToolbar,EpochNavigation} from './EpochBrowserChrome.jsx'; +import EpochBrowserLayout from './EpochBrowserLayout.jsx'; +import EpochTreePane from './EpochTreePane.jsx'; +import TreeBuilder from './TreeBuilder.jsx'; +import PagedTree from './PagedTree.jsx'; +import StableContent from './StableContent.jsx'; +import EpochDetailHeading from './EpochDetailHeading.jsx'; +import EpochAnalysisInclusion from './EpochAnalysisInclusion.jsx'; +import Trace from './TraceViewer.jsx'; +import MetadataPanel from './MetadataPanel.jsx'; +import SelectionOverview from './SelectionOverview.jsx'; +import ProtocolViewFilter from './ProtocolViewFilter.jsx'; +import {clearTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; +import {Empty} from './Common.jsx'; + +// Source adapters provide data and mutations; every viewer assembles its UI here. +export default function EpochViewer({className='epoch-inspector-mode',ariaLabel='Epoch inspection',onKeyDown,toolbar,toolbarChildren,viewFilters,onViewFilters,filterRevision,filterDisabled=false,before,layout,designMode=false,builder,columnTree,treePane,resource={},epoch,targets=[],navigation,traceRevision,inclusion,detailDisabled=false,onQC,tags,detailExtras,metadata}){ + const detail={targets.length?:epoch?<> + + {navigation&&} + +
{inclusion&&} {!layout.metadataOpen&&(!layout.treeOpen||treePane?.treeMode)&&tags}
+ {detailExtras} + :Select an epoch from the tree to inspect its response and metadata.}
; + return
+ {toolbar&&:toolbar.filterControl} treeControlsInPane>{toolbarChildren}{!designMode&&onViewFilters&&tagFilterLabel(viewFilters)&&{tagFilterLabel(viewFilters)}}} + {before} + :{!layout.metadataOpen&&{tags}}} + detail={designMode?:detail} + metadata={metadata&&}/> +
; +} diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 58b4489..6604e08 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -108,7 +108,7 @@ export default function HierarchyTree(props){ const label=page.kind==='epochs'?'Epochs':readableField(field?.label,field?.field); return
    {entries.map((item,index)=>{ - if(page.kind==='epochs')return
  • {props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.curation?.included===false?'Excluded':'Included'}}
  • ; + if(page.kind==='epochs'){const epoch=props.inclusionForEpoch?props.inclusionForEpoch(item):item;return
  • {props.onToggleInclusion&&}{!props.onToggleInclusion&&{epoch.curation?.included===false?'Excluded':'Included'}}
  • ;} const key=hierarchyKey(item.path),opened=state.expanded.some(path=>hierarchyKey(path)===key),children=state.pages.find(child=>hierarchyKey(child.path)===key),loading=state.loading&&hierarchyKey(state.loadingPath||[])===key; return
  • {opened&&(children?renderPage(children):
    {loading?'Loading branch…':'Branch is not loaded.'}
    )}
  • ; })} diff --git a/workspace-app/src/components/InspectionCellTree.jsx b/workspace-app/src/components/InspectionCellTree.jsx index 28d737c..e3b43e8 100644 --- a/workspace-app/src/components/InspectionCellTree.jsx +++ b/workspace-app/src/components/InspectionCellTree.jsx @@ -9,11 +9,11 @@ import {Status} from './Common.jsx'; import './InspectionCellTree.css'; import {useEpochBrowserPage} from '../useEpochBrowserPage.js'; -function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disabled,onToggleInclusion}){ +function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disabled,onToggleInclusion,inclusionForEpoch}){ const [offset,setOffset]=useState(0); const page=useEpochBrowserPage(source,{cellUuid:cell.cell_uuid,offset},revision); return - {(page.data?.epochs||[]).map((epoch,index)=>
    + {(page.data?.epochs||[]).map((record,index)=>{const epoch=inclusionForEpoch?inclusionForEpoch(record):record;return
    {onToggleInclusion&&} -
    )} +
    ;})} {page.data&&
    {page.data.total?offset+1:0}–{Math.min(offset+60,page.data.total)} of {number(page.data.total)}
    }
    ; } diff --git a/workspace-app/src/components/Inspector.css b/workspace-app/src/components/Inspector.css index f958787..68ec0a8 100644 --- a/workspace-app/src/components/Inspector.css +++ b/workspace-app/src/components/Inspector.css @@ -19,20 +19,13 @@ .analysis-inclusion-help{font-size:12px;color:var(--ink-secondary);line-height:1.5} .inspector .curation-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} -/* One editing action above a compact list-view switch. */ -.epoch-inspector-mode .tree-heading{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:14px} -.epoch-inspector-mode .tree-heading h3{margin:0;font-size:13px;line-height:1.5} -.epoch-inspector-mode .tree-heading .segmented{display:flex;flex:0 1 auto;margin:0;width:auto;max-width:100%} -.epoch-inspector-mode .tree-heading .segmented button{font-size:11px;padding:6px 11px;flex:0 1 auto;white-space:nowrap} +/* One shared, single-row header adapts to the actual tree pane width. */ +.epoch-inspector-mode .inspection-tree{container:epoch-tree-pane / inline-size} +.epoch-inspector-mode .tree-heading{display:flex;flex-wrap:nowrap;align-items:center;justify-content:space-between;gap:4px;padding:10px 6px;border-bottom:1px solid var(--border);flex-shrink:0} +.epoch-inspector-mode .tree-heading .segmented{display:flex;flex:0 1 auto;margin:0;width:auto;min-width:0} +.epoch-inspector-mode .tree-heading .segmented button{font-size:11px;padding:6px 7px;flex:0 1 auto;white-space:nowrap} +.epoch-inspector-mode .tree-heading .tree-collapse-action,.epoch-inspector-mode .tree-heading .tree-edit-action{margin:0;white-space:nowrap;font-size:11px;padding:6px;flex-shrink:0} +.epoch-inspector-mode .tree-heading .tree-collapse-action>svg{display:none} .epoch-inspector-mode .compact-inspector-toolbar{padding:8px 14px;gap:10px} - -.epoch-inspector-mode .tree-heading{justify-content:center;gap:12px} -.epoch-inspector-mode .tree-heading .segmented{margin-left:auto} -.epoch-inspector-mode .tree-heading .tree-edit-action{margin-left:auto;white-space:nowrap;font-size:11px;padding:6px 8px} - -.epoch-inspector-mode .tree-heading{gap:8px;justify-content:space-between} -.epoch-inspector-mode .tree-heading .tree-collapse-action{font-size:11px;padding:6px 8px;white-space:nowrap} -.epoch-inspector-mode .tree-heading .segmented{margin-left:0} -.epoch-inspector-mode .tree-heading .tree-edit-action{margin-left:0} - -.epoch-inspector-mode .tree-heading{border-bottom:1px solid var(--border);flex-shrink:0} +@container epoch-tree-pane (max-width:340px){.tree-heading .tree-edit-action span{display:none}.tree-heading .tree-edit-action{width:28px;justify-content:center}} +@container epoch-tree-pane (max-width:220px){.epoch-inspector-mode .tree-heading .segmented button{font-size:10px;padding:6px 3px}.tree-heading .tree-collapse-action{width:28px;justify-content:center}.tree-heading .tree-collapse-action>span{display:none}.epoch-inspector-mode .tree-heading .tree-collapse-action>svg{display:block}} diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 0ed3cda..21d99b9 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -1,9 +1,6 @@ -import EpochDetailHeading from './EpochDetailHeading.jsx'; -import {clearTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; -import ProtocolViewFilter from './ProtocolViewFilter.jsx'; +import EpochViewer from './EpochViewer.jsx'; import SelectionMaskDialog from './SelectionMaskDialog.jsx'; import {NavigationLoadingProvider} from './NavigationLoading.jsx'; -import StableContent from './StableContent.jsx'; import {advanceEpochIntent,epochIntentAt,epochAtIntent} from '../epochNavigationIntent.js'; import {SourceEligibilityNotice} from './Common.jsx'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -11,15 +8,9 @@ import { ChevronDown, ChevronRight, GitBranch, Check, X, MessageCircle, ArrowLef import { api, useResource, useEpochResource, useEpochPrefetch, number, humanize, resolveCurationTargets } from '../api.js'; import { Status, Metadata, Badge, Empty } from './Common.jsx'; import EpochConnections from './EpochConnections.jsx'; -import TreeBuilder from './TreeBuilder.jsx'; -import TreePreview from './TreePreview.jsx'; import './InspectorPolish.css'; import './MatlabMaskImport.css'; -import MetadataPanel from './MetadataPanel.jsx'; -import EpochTreePane from './EpochTreePane.jsx'; -import {EpochBrowserToolbar,EpochSelectionBar,EpochNavigation} from './EpochBrowserChrome.jsx'; import EpochTags from './EpochTags.jsx'; -import SelectionOverview from './SelectionOverview.jsx'; import AnnotationTags from './AnnotationTags.jsx'; import TagExchangeControls from './TagExchangeControls.jsx'; import './Inspector.css'; @@ -30,8 +21,6 @@ import {datedCellLabel} from '../recordingIdentity.js'; import {restoredEpochFocus} from '../workspaceNavigation.js'; import {boundedTreePage,inspectionTreeFocus} from '../boundedTree.js'; import Trace from './TraceViewer.jsx'; -import EpochBrowserLayout from './EpochBrowserLayout.jsx'; -import PagedTree from './PagedTree.jsx'; import {inspectorPaneSizes,epochShortcutDirection,resourceForPath} from '../inspectorInteraction.js'; export {Trace}; @@ -280,15 +269,14 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi const actionScope=targets.length?`${targets.length} selected`:'focused epoch'; const annotationChanged=()=>{epoch.reload();loadedRows.reload();onChange?.();}; const tagEntry=focusedEpoch&&}>curate({tags_add:[value]})} onRemove={value=>curate({tags_remove:[value]},'focused')} selectedTags={focusedEpoch.curation?.tags||[]} busy={busy} scope={`dataset · ${actionScope}`} revision={revision}/>; - return
    - } designMode={designMode} onBrowse={()=>{setDesignMode(false);setTreeMode(false);setTreeOpen(true);}} onDesign={()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);}} onTags={openTagsForSelection} metadataOpen={metadataOpen} onToggleMetadata={()=>toggleMetadata(!metadataOpen)} actions={[ + return {setDesignMode(false);setTreeMode(false);setTreeOpen(true);},onDesign:()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);},onTags:openTagsForSelection,metadataOpen:metadataOpen,onToggleMetadata:()=>toggleMetadata(!metadataOpen),actions:[ {label:treeOpen?(designMode?'Hide tree editor':'Hide epoch list'):(designMode?'Show tree editor':'Show epoch list'),icon:GitBranch,run:()=>setTreeOpen(value=>!value)}, !designMode&&{label:'Import mask file…',icon:FileJson,run:()=>setMasksOpen(true),disabled:busy}, !designMode&&onImport&&{label:'Add data store',icon:Upload,run:onImport,disabled:busy}, - ]}> + ]}} toolbarChildren={<> {focusCell&&} - {!designMode&&tagFilterLabel(filters)&&{tagFilterLabel(filters)}} - + } before={<> {!designMode&&<> @@ -309,30 +297,13 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi } }{!designMode&&maskMessage&&
    {maskMessage}
    } {error&&
    {error}
    } - :{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));}} - onDesign={()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);}} designDisabled={busy} - collapseRequest={collapseRequest} onCollapse={()=>setCollapseRequest(value=>value+1)} - treeProps={{actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},protocolId:id,filters,splits,revision,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onSelectEpoch:focusTreeEpoch,onMetadata:receiveTree,onStatus:setTreeStatus}} - listRef={epochListRef} listKey={`${id}:${protocolSearch}`} - listProps={{cells:protocol.cells||[],source:{kind:'protocol',protocolId:id,query:protocolSearch},revision,focused:cellTagRequest?null:focused,onFocus:focusTreeEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets:selectOverviewTargets,disabled:busy,onToggleInclusion:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}}> - {!metadataOpen&&{tagEntry}} - } - detail={designMode?curate({included},'focused',item.epoch_uuid)} cells={protocol.cells} selectedEpochs={targets} setSelectedEpochs={selectOverviewTargets} selectedCell={cellTagRequest?.cell_uuid} onSelectCell={(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});}} presentation="columns" protocolId={id} filters={filters} splits={splits} revision={revision} design selected={focused} initialNavigation={designNavigation} onNavigationChange={setDesignNavigation} onMetadata={receiveTree} onStatus={setTreeStatus} onSelectEpoch={(uuid,info)=>{focusTreeEpoch(uuid,info);setTreeMode(false);setDesignMode(false);}}/>:{targets.length>0?:focusedEpoch?<> - - - - -
    - - {focusedEpoch.curation?.included===false?'Excluded from this pinned dataset’s analysis exports. Still available to inspect.':'Included in this pinned dataset’s analysis exports.'} - {!metadataOpen&&(!treeOpen||treeMode)&&tagEntry} -
    - {busy&&

    {operationMessage}

    } +} + layout={{layoutRef,sizes:paneSizes,treeOpen,metadataOpen,onResize:changePane,onResizeCommit:savePane}} designMode={designMode} + builder={{protocolId:id,queryString:protocolSearch,revision:revision,value:splits.split(',').filter(Boolean),onChange:changeSplits,preview:tree.data,loading:tree.loading,error:tree.error}} columnTree={{collapseRequest:collapseRequest,externalCollapseControl:true,actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},presentation:"columns",protocolId:id,filters:filters,splits:splits,revision:revision,design:true,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onMetadata:receiveTree,onStatus:setTreeStatus,onSelectEpoch:(uuid,info)=>{focusTreeEpoch(uuid,info);setTreeMode(false);setDesignMode(false);}}} treePane={{treeMode:treeMode,onTreeMode:value=>{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));},onDesign:()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);},designDisabled:busy,collapseRequest:collapseRequest,onCollapse:()=>setCollapseRequest(value=>value+1),treeProps:{actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},protocolId:id,filters,splits,revision,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onSelectEpoch:focusTreeEpoch,onMetadata:receiveTree,onStatus:setTreeStatus},listRef:epochListRef,listKey:`${id}:${protocolSearch}`,listProps:{cells:protocol.cells||[],source:{kind:'protocol',protocolId:id,query:protocolSearch},revision,focused:cellTagRequest?null:focused,onFocus:focusTreeEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets:selectOverviewTargets,disabled:busy,onToggleInclusion:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}}} + resource={{scope:id,...epoch,loading:!epoch.error&&(!!pendingNavigation||(!!focused&&(epoch.loading||!focusedEpoch))),retry:epoch.reload}} + epoch={focusedEpoch} targets={targets} navigation={{position:focusedPageIndex<0?-1:offset+focusedPageIndex,total:rows.data?.total||0,loading:!!pendingNavigation||rows.loading,disabled:busy,onMove:moveEpoch}} + traceRevision={revision} inclusion={{disabled:busy,scope:'pinned dataset',onToggle:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}} detailDisabled={busy} onQC={onQC} tags={tagEntry} + detailExtras={<> {busy&&

    {operationMessage}

    }
    Dataset-only tags: {(focusedEpoch.curation?.tags||[]).map(t=>)} {!focusedEpoch.curation?.tags?.length&&No tags} @@ -343,11 +314,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi - - :Select an epoch from the tree to inspect its response and metadata.}} - metadata={setTargets([])} tags={tagEntry} epoch={focusedEpoch} catalog={metadataCatalog} onClose={()=>toggleMetadata(false)} context={focusedEpoch&&} connections={focusedEpoch&&}/>}/> - -
    ; + } metadata={{selectionCell:cellTagRequest,selectedEpochs:targets,onClearSelection:()=>setTargets([]),catalog:metadataCatalog,onClose:()=>toggleMetadata(false),context:focusedEpoch&&,connections:focusedEpoch&&}}/>; } export default function Inspector(props){return ;} diff --git a/workspace-app/src/components/MatchingEpochs.jsx b/workspace-app/src/components/MatchingEpochs.jsx index 3da99e7..8865395 100644 --- a/workspace-app/src/components/MatchingEpochs.jsx +++ b/workspace-app/src/components/MatchingEpochs.jsx @@ -1,25 +1,17 @@ -import EpochDetailHeading from './EpochDetailHeading.jsx'; +import EpochViewer from './EpochViewer.jsx'; import {NavigationLoadingProvider} from './NavigationLoading.jsx'; -import StableContent from './StableContent.jsx'; import {advanceEpochIntent,epochIntentAt,epochAtIntent} from '../epochNavigationIntent.js'; import {useEffect,useRef,useState} from 'react'; import {GitBranch,ListFilter} from 'lucide-react'; import {useEpochResource,useEpochPrefetch,humanize} from '../api.js'; import {useEpochBrowserPage} from '../useEpochBrowserPage.js'; import {epochShortcutDirection,inspectorPaneSizes} from '../inspectorInteraction.js'; -import {Trace} from './Inspector.jsx'; -import EpochTreePane from './EpochTreePane.jsx'; -import {EpochBrowserToolbar,EpochSelectionBar,EpochNavigation} from './EpochBrowserChrome.jsx'; -import MetadataPanel from './MetadataPanel.jsx'; import EpochConnections from './EpochConnections.jsx'; -import EpochBrowserLayout from './EpochBrowserLayout.jsx'; -import {Empty} from './Common.jsx'; import './MatchingEpochs.css'; -import SelectionOverview from './SelectionOverview.jsx'; import AnnotationTags from './AnnotationTags.jsx'; import TagExchangeControls from './TagExchangeControls.jsx'; -function MatchingEpochsContent({toolbarTarget=null,onFilter,filterDisabled=false,predicate,splits,preview,onRefresh,session,onSession,onTagFilter,onAnnotationsChanged,onDesign,designDisabled=false,onExport,exportDisabled=false,actions=[]}){ +function MatchingEpochsContent({toolbarTarget=null,viewFilters,onViewFilters,filterRevision,filterDisabled=false,predicate,splits,preview,onRefresh,session,onSession,onTagFilter,onAnnotationsChanged,onDesign,designDisabled=false,onExport,exportDisabled=false,actions=[],inclusionForEpoch,onToggleInclusion,onQC}){ const navigationIntent=useRef(null); const saved=useRef(session?.revision===preview.tree_revision?session:null).current; const [focused,setFocused]=useState(saved?.focused||null),[request,setRequest]=useState(saved?.focused?{anchorUuid:saved.focused}:{offset:0}); @@ -62,24 +54,18 @@ function MatchingEpochsContent({toolbarTarget=null,onFilter,filterDisabled=false function annotationsChanged(){epoch.reload();setAnnotationRevision(value=>value+1);setAnnotationNotice(onAnnotationsChanged?'':'Tags saved. Refresh predicate results to re-evaluate tag conditions.');onAnnotationsChanged?.();} const tagEntry=detail&&{move(direction);setEpochTagFocus(value=>value+1);}} onChange={annotationsChanged} onFilter={onTagFilter} tools={!cellTagRequest&&!targets.length&&}/>; const detailLoading=!!focused&&!page.error&&!epoch.error&&!detail; - return
    {if(!focused)return;const direction=epochShortcutDirection(event);if(direction){event.preventDefault();event.stopPropagation();event.currentTarget.focus({preventScroll:true});move(direction);}}}> - Filter} onExport={onExport} exportDisabled={exportDisabled} onBrowse={()=>{setTreeMode(false);setTreeOpen(true);}} onDesign={onDesign} designDisabled={designDisabled} onTags={openTags} metadataOpen={metadataOpen} onToggleMetadata={()=>toggleMetadata(!metadataOpen)} actions={[{label:treeOpen?'Hide epoch list':'Show epoch list',icon:GitBranch,run:()=>setTreeOpen(value=>!value)},...actions]}/> + return {if(!focused)return;const direction=epochShortcutDirection(event);if(direction){event.preventDefault();event.stopPropagation();event.currentTarget.focus({preventScroll:true});move(direction);}}} + toolbar={{portalTarget:toolbarTarget,treeControlsInPane:true,onExport:onExport,exportDisabled:exportDisabled,onBrowse:()=>{setTreeMode(false);setTreeOpen(true);},onDesign:onDesign,designDisabled:designDisabled,onTags:openTags,metadataOpen:metadataOpen,onToggleMetadata:()=>toggleMetadata(!metadataOpen),actions:[{label:treeOpen?'Hide epoch list':'Show epoch list',icon:GitBranch,run:()=>setTreeOpen(value=>!value)},...actions]}} before={<> {annotationNotice&&
    {annotationNotice}
    } {page.error&&
    {page.error}
    } - setSizes(old=>({...old,[name]:value}))} - tree={{setTreeMode(value);if(value)setSizes(old=>({...old,tree:Math.max(420,pane.tree)}));}} - onDesign={onDesign} designDisabled={designDisabled} collapseRequest={collapseRequest} onCollapse={()=>setCollapseRequest(value=>value+1)} - treeProps={{cells,selectedEpochs:targets,setSelectedEpochs:setTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest((cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},predicate,splits,revision:preview.tree_revision,expectedRevision:preview.tree_revision,onRefreshPreview:onRefresh,selected:focused,onSelectEpoch:selectEpoch}} - listStatus={{data:cells,loading:!cells&&page.loading,error:page.error,retry:onRefresh}} - listProps={{cells:cells||[],source,revision:preview.tree_revision,focused:cellTagRequest?null:focused,onFocus:selectEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets,disabled:!!page.error}} childrenInTree> - {!metadataOpen&&{tagEntry}} - } - detail={{targets.length>0?:detail?<>{!metadataOpen&&!treeOpen&&tagEntry}:Select an epoch from the tree to inspect its response and metadata.}} - metadata={setTargets([])} tags={tagEntry} epoch={detail} catalog={{data:preview.catalog}} onClose={()=>toggleMetadata(false)} connections={detail&&}/>}/> - -
    ; +} + layout={{layoutRef:layout,sizes:pane,treeOpen,metadataOpen,onResize:(name,value)=>setSizes(old=>({...old,[name]:value}))}} + treePane={{treeMode:treeMode,onTreeMode:value=>{setTreeMode(value);if(value)setSizes(old=>({...old,tree:Math.max(420,pane.tree)}));},onDesign:onDesign,designDisabled:designDisabled,collapseRequest:collapseRequest,onCollapse:()=>setCollapseRequest(value=>value+1),treeProps:{actionsDisabled:filterDisabled,inclusionForEpoch,onToggleInclusion,cells,selectedEpochs:targets,setSelectedEpochs:setTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest((cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},predicate,splits,revision:preview.tree_revision,expectedRevision:preview.tree_revision,onRefreshPreview:onRefresh,selected:focused,onSelectEpoch:selectEpoch},listStatus:{data:cells,loading:!cells&&page.loading,error:page.error,retry:onRefresh},listProps:{inclusionForEpoch,onToggleInclusion,cells:cells||[],source,revision:preview.tree_revision,focused:cellTagRequest?null:focused,onFocus:selectEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets,disabled:!!page.error||filterDisabled}}} resource={{...epoch,blocked:!!page.error,loading:detailLoading,retry:epoch.reload}} + epoch={detail&&inclusionForEpoch?inclusionForEpoch(detail):detail} targets={targets} + navigation={{position:index<0?-1:offset+index,total:page.data?.total||0,loading:page.loading,onMove:move}} + traceRevision={detailRevision} detailDisabled={!currentPage||epoch.loading} onQC={onQC} + inclusion={onToggleInclusion?{disabled:filterDisabled||!currentPage||epoch.loading,onToggle:onToggleInclusion}:null} tags={tagEntry} + metadata={{selectionCell:cellTagRequest,selectedEpochs:targets,onClearSelection:()=>setTargets([]),catalog:{data:preview.catalog},onClose:()=>toggleMetadata(false),connections:detail&&}}/>; } export default function MatchingEpochs(props){return ;} diff --git a/workspace-app/src/components/MetadataExplorer.css b/workspace-app/src/components/MetadataExplorer.css index 9bc2b57..9b59856 100644 --- a/workspace-app/src/components/MetadataExplorer.css +++ b/workspace-app/src/components/MetadataExplorer.css @@ -25,3 +25,5 @@ .metadata-explorer>.mx-heading.mx-heading-compact{padding:6px 12px;min-height:46px;align-items:center;gap:12px;position:relative;z-index:45}.mx-heading-compact>strong{font-size:13px;white-space:nowrap}.mx-heading-compact .mx-header-actions{margin-left:auto;gap:7px;flex-wrap:nowrap}.mx-heading-compact .mx-header-actions>button,.mx-heading-compact .inspector-more>button{font-size:12px;padding:6px 9px}.matching-compact-toolbar>strong{font-size:12px}.matching-compact-toolbar .matching-context{font-size:11px;color:var(--ink-secondary)} .metadata-explorer>.mx-heading.mx-heading-compact{flex-wrap:wrap;row-gap:6px}.mx-heading-compact .mx-header-actions:empty{display:none} + +.explorer-design-viewer{flex:1;min-height:0} diff --git a/workspace-app/src/components/MetadataExplorer.jsx b/workspace-app/src/components/MetadataExplorer.jsx index 5dc9297..95ac24a 100644 --- a/workspace-app/src/components/MetadataExplorer.jsx +++ b/workspace-app/src/components/MetadataExplorer.jsx @@ -1,3 +1,6 @@ +import EpochViewer from './EpochViewer.jsx'; +import {predicateWithTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; +import {searchInclusionPredicate,searchEpochInclusion,toggleSearchInclusion} from '../searchInclusion.js'; import SearchPresets from './SearchPresets.jsx'; import QueryPresetDialog from './QueryPresetDialog.jsx'; import {searchPresetPayload,validatePresetReceipt} from '../projectSearchPresets.js'; @@ -14,21 +17,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, ArrowRight, Check, ChevronDown, Search, Filter, Download, GitBranch, History, LoaderCircle, RefreshCw, Save, Plus, X } from 'lucide-react'; import { api, humanize, number, time, useResource } from '../api.js'; import { Badge, Metadata, Status } from './Common.jsx'; -import { Trace } from './Inspector.jsx'; -import TreeBuilder from './TreeBuilder.jsx'; -import PagedTree from './PagedTree.jsx'; -import EpochBrowserLayout from './EpochBrowserLayout.jsx'; import {inspectorPaneSizes} from '../inspectorInteraction.js'; -import EpochConnections from './EpochConnections.jsx'; -import ProtocolApplyPanel from './ProtocolApplyPanel.jsx'; import { compilePredicate, conditionCount, newCondition, newGroup, predicateToDraft } from './predicateState.js'; import './MetadataExplorer.css'; const initialSearch=()=>({...newGroup(),children:[{...newCondition(),field:'protocol',operator:'contains'}]}); function draftOf(predicate){const node=predicateToDraft(predicate);return node.kind==='group'?node:{...newGroup(),children:[node]};} -export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,projectId,revision=0,protocols=[],onInspect,initialPredicate=null,initialRevisionId=null,initialProtocolId=null,initialExportIntent=null,onChange,onProtocolApplied,onNewSearch,onExit,session=null,onSession}) { +export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,projectId,revision=0,protocols=[],onInspect,initialPredicate=null,initialRevisionId=null,initialProtocolId=null,initialExportIntent=null,onChange,onProtocolApplied,onNewSearch,onExit,onQC,session=null,onSession}) { const saved=useRef(session).current; const [draft,setDraft]=useState(()=>saved?.draft || (initialPredicate?draftOf(initialPredicate):initialSearch())); + const [viewFilters,setViewFilters]=useState(saved?.viewFilters||{}),[filteredPreview,setFilteredPreview]=useState({data:null,loading:false,error:null,key:null}); + const [excludedEpochs,setExcludedEpochs]=useState(saved?.excludedEpochs||[]),[exportCandidate,setExportCandidate]=useState(null); const [name,setName]=useState(saved?.name ?? 'Metadata selection'); const [filterSplits,setFilterSplits]=useState(saved?.filterSplits ?? 'date,protocol,cell'); const [splits,setSplits]=useState(saved?.splits ?? 'date,protocol,cell'); @@ -61,7 +60,6 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, const layoutRef=useRef(null),[layoutWidth,setLayoutWidth]=useState(1100); const [groupingWidth,setGroupingWidth]=useState(()=>{try{const value=Number(localStorage.getItem('workspace.explorer.groupingWidth'));return value>=240?value:320;}catch{return 320;}}); const pane=inspectorPaneSizes(layoutWidth,{tree:groupingWidth},true,false); - useEffect(()=>{const node=layoutRef.current;if(!node)return;const observer=new ResizeObserver(entries=>setLayoutWidth(entries[0].contentRect.width));observer.observe(node);return()=>observer.disconnect();},[step,focused,initialCandidateLoading]); useEffect(()=>setPagedInfo(null),[splits,applied?.revision_uuid,generation]); // Tag vocabulary should refresh even while the currently browsed membership is held. const catalog=useResource('/explore/predicate-fields',`${revision}:${generation}`); @@ -85,7 +83,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, const focusedInScope=focusState==='included'; const matches=epoch.data?protocols.filter(protocol=>!protocol.binding&&(protocol.acquisition_protocol===epoch.data.protocol_name || protocol.query?.all?.some(condition=>condition.field==='EpochBlock.protocol_name'&&condition.operator==='eq'&&condition.value===epoch.data.protocol_name))):[]; - useEffect(()=>{onSession?.(snapshotExplorerState({draft,name,filterSplits,splits,step,resultsFromDraft,matchingNavigation,path,treeNavigation,focused,applied,restored,historyOffset,activePreset,wasBusy:busy}));},[draft,name,filterSplits,splits,step,resultsFromDraft,matchingNavigation,path,treeNavigation,focused,applied,restored,historyOffset,activePreset,busy,onSession]); + useEffect(()=>{onSession?.(snapshotExplorerState({viewFilters,excludedEpochs,draft,name,filterSplits,splits,step,resultsFromDraft,matchingNavigation,path,treeNavigation,focused,applied,restored,historyOffset,activePreset,wasBusy:busy}));},[viewFilters,excludedEpochs,draft,name,filterSplits,splits,step,resultsFromDraft,matchingNavigation,path,treeNavigation,focused,applied,restored,historyOffset,activePreset,busy,onSession]); useEffect(()=>{ if(saved?.applied&&initialCandidateRetry===0){setInitialCandidateLoading(false);return;} if(!initialRevisionId){setInitialCandidateLoading(false);return;} @@ -93,7 +91,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, api(`/explore/revisions/${initialRevisionId}?summary=1`,{signal:controller.signal}).then(result=>{ if(controller.signal.aborted)return; if(!result.recipe||!result.revision_uuid)throw new Error('This candidate does not contain a saved recipe.'); - setApplied(result);setDraft(draftOf(result.recipe.predicate));setName(initialExportIntent?.name || result.recipe.name || 'Protocol candidate'); + setApplied(result);if(initialExportIntent)setExportCandidate(result);setDraft(draftOf(result.recipe.predicate));setName(initialExportIntent?.name || result.recipe.name || 'Protocol candidate'); setSplits(result.recipe.splits);setFilterSplits(result.recipe.splits);setRestored(null);setFocused(null);setPath([]);setTreeNavigation(null);setStep('results');setResultsFromDraft(false); setNotice(`Reviewing saved candidate ${result.revision_uuid.slice(0,8)}. The protocol working dataset stays unchanged until you apply it.`); }).catch(error=>{if(!controller.signal.aborted)setError(error.message);}).finally(()=>{if(!controller.signal.aborted)setInitialCandidateLoading(false);}); @@ -133,7 +131,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, setApplied({...result,previewGeneration:generation,previewRevision:viewRevision});if(result.preview)setTree({data:result.preview,loading:false,error:null});if(notifyParent)onChange?.();setSplits(result.recipe.splits);setFilterSplits(result.recipe.splits);setPath([]);setTreeNavigation(null);setFocused(null);setStep(nextStep);setResultsFromDraft(false); if(kind==='filter'){setDraft(draftOf(result.recipe.predicate));setRestored(null);} storePreset({name:body.name,predicate,splits:result.recipe.splits,matched_count:result.summary?.matched_count??result.preview?.matched_count}); - setHistoryVersion(value=>value+1);setHistoryOffset(0);setNotice(`Recorded revision ${result.revision_uuid.slice(0,8)}. Its query and exact epoch membership are preserved.`);return result; + setHistoryVersion(value=>value+1);setHistoryOffset(0);setNotice(nextStep==='tree'?'':`Recorded revision ${result.revision_uuid.slice(0,8)}. Its query and exact epoch membership are preserved.`);return result; }catch(error){setError(error.message);}finally{setBusy(false);setBusyAction(null);} } async function presetRecipe(item){ @@ -154,7 +152,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, if(action==='edit'){setActivePreset(item.preset_uuid?item:null);setImportedPreset(null);setDraft(nextDraft);setName(nextName);setFilterSplits(grouping);setRestored(null);setEditorOpen(true);return;} const result=await api('/explore/run',{method:'POST',body:{predicate,splits:grouping},signal:controller.signal}); if(controller.signal.aborted)return; - setAnnotationHold(null);setActivePreset(item.preset_uuid?item:null);setImportedPreset(null);setDepartedInitial(true);setDraft(nextDraft);setName(nextName);setFilterSplits(grouping);setSplits(grouping);setRestored(null);setApplied(null);setTreeNavigation(null);setMatchingNavigation(null);setFocused(null);setDestination(null); + setViewFilters({});setExcludedEpochs([]);setExportCandidate(null);setAnnotationHold(null);setActivePreset(item.preset_uuid?item:null);setImportedPreset(null);setDepartedInitial(true);setDraft(nextDraft);setName(nextName);setFilterSplits(grouping);setSplits(grouping);setRestored(null);setApplied(null);setTreeNavigation(null);setMatchingNavigation(null);setFocused(null);setDestination(null); setDraftPreview({data:result,loading:false,error:null,key:predicateIdentity({predicate,splits:grouping})});setResultsFromDraft(true);setStep('results');setEditorOpen(false); storePreset({name:nextName,predicate,splits:grouping,matched_count:result.matched_count,cell_count:result.last_run.cell_count,lastRunAt:result.last_run.ran_at});projectPresets.reload(); }catch(error){if(!controller.signal.aborted)setError(error.message);}finally{if(!controller.signal.aborted)setBusy(false);} @@ -174,33 +172,62 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, setPresetDialog({predicate:structuredClone(resultPredicate),splits:resultSplits,preset:projectPresets.data?.presets?.find(item=>item.preset_uuid===activePreset?.preset_uuid)||activePreset,defaults:{name:name==='Metadata selection'?predicateSummary(resultPredicate).slice(0,160):name,description:importedPreset?.description||''}}); } function savedQueryPreset(row){setActivePreset(row);setName(row.name);setPresetDialog(null);setImportedPreset(null);setPresetOffset(0);projectPresets.reload();setNotice(`${row.reused?'Using existing':'Saved'} “${row.name}” in this project. Rerunning it queries current data; the current selection and protocol datasets are unchanged.`);onChange?.();} - const resultPreview=resultsFromDraft?currentDraftPreview:tree.data; - const resultPredicate=resultsFromDraft?compiled.predicate:applied?.recipe.predicate; + const baseResultPreview=resultsFromDraft?currentDraftPreview:tree.data; + const baseResultPredicate=resultsFromDraft?compiled.predicate:applied?.recipe.predicate; const resultSplits=resultsFromDraft?filterSplits:splits; - const resultLoading=resultsFromDraft?draftPreview.loading:tree.loading; - const resultError=resultsFromDraft?draftPreview.error:tree.error; + const resultPredicate=baseResultPredicate?predicateWithTagFilters(baseResultPredicate,viewFilters):null; + const hasViewFilter=!!tagFilterLabel(viewFilters); + const filteredKey=predicateIdentity({predicate:resultPredicate,splits:resultSplits,revision:viewRevision,generation}); + const currentFilteredPreview=filteredPreview.key===filteredKey?filteredPreview.data:null; + const resultPreview=hasViewFilter?currentFilteredPreview:baseResultPreview; + const resultLoading=hasViewFilter?filteredPreview.loading||!currentFilteredPreview&&!filteredPreview.error:(resultsFromDraft?draftPreview.loading:tree.loading); + const resultError=hasViewFilter&&filteredPreview.key===filteredKey?filteredPreview.error:(resultsFromDraft?draftPreview.error:tree.error); + const displayTree=hasViewFilter?{data:currentFilteredPreview,loading:resultLoading,error:resultError}:tree; + useEffect(()=>{const node=layoutRef.current;if(!node)return;const observer=new ResizeObserver(entries=>setLayoutWidth(entries[0].contentRect.width));observer.observe(node);return()=>observer.disconnect();},[step,focused,initialCandidateLoading,!!displayTree.data,displayTree.loading,!!displayTree.error]); + useEffect(()=>{ + if(!hasViewFilter||!resultPredicate||step==='filter'||!baseResultPreview)return; + const controller=new AbortController(); + setFilteredPreview({data:null,loading:true,error:null,key:filteredKey}); + api('/explore/preview',{method:'POST',body:{predicate:resultPredicate,splits:resultSplits,summary_only:true},signal:controller.signal}) + .then(data=>{if(!controller.signal.aborted)setFilteredPreview({data,loading:false,error:null,key:filteredKey});}) + .catch(error=>{if(!controller.signal.aborted)setFilteredPreview({data:null,loading:false,error:error.message,key:filteredKey});}); + return()=>controller.abort(); + },[hasViewFilter,filteredKey,step,!!baseResultPreview]); + function changeViewFilters(next){setViewFilters(next);setMatchingNavigation(null);setTreeNavigation(null);setPagedInfo(null);setDestination(null);setExportCandidate(null);} + useEffect(()=>{if(step==='results'&&resultsFromDraft&&!currentDraftPreview&&!draftPreview.loading&&draftPreview.key!==draftKey&&compiled.predicate)previewDraft();},[step,resultsFromDraft,draftKey,currentDraftPreview,draftPreview.loading,draftPreview.key]); function annotationsChanged(){ setAnnotationHold(current=>current||{revision:viewRevision});setDestination(null);onChange?.(); } function refreshResults(){ - if(resultsFromDraft)previewDraft();else{setAnnotationHold(null);setGeneration(value=>value+1);} + if(resultsFromDraft){if(hasViewFilter)setGeneration(value=>value+1);previewDraft();}else{setAnnotationHold(null);setGeneration(value=>value+1);} } async function prepareDestination(next,fromDraft=resultsFromDraft){ if(annotationHold){setError('Refresh results after editing annotations before using this selection.');return;} + if(next==='export'){ + if(busy||!resultPredicate)return; + setBusy(true);setError(''); + try{ + const result=await api('/explore/revisions',{method:'POST',body:{predicate:searchInclusionPredicate(resultPredicate,excludedEpochs),splits:resultSplits,name:name.trim()||'Metadata selection',summary_only:true,...(applied?.revision_uuid?{parent_revision_uuid:applied.revision_uuid}:{})}}); + if(!result.recipe||!result.revision_uuid)throw new Error('The server did not return an export selection.'); + if(!(result.summary?.matched_count??result.recipe.epoch_count??result.recipe.epochs?.length)){setError('No included epochs remain. Include an epoch before exporting.');return;} + setExportCandidate(result);setHistoryVersion(value=>value+1);setDestination('export'); + }catch(error){setError(error.message);}finally{setBusy(false);} + return; + } if(fromDraft||!applied||changedTree||changedMembership){const result=await saveRevision(fromDraft?'filter':'tree',next==='tree'?'tree':'results',next!=='export');if(!result)return;} if(next==='tree'){setStep('tree');setDestination(null);}else setDestination(next); } const previousOpenRequest=useRef(openRequest); useEffect(()=>{if(previousOpenRequest.current!==openRequest&&!busy){previousOpenRequest.current=openRequest;editSearch();}},[openRequest,busy]); function editSearch(){ - if(step!=='filter'&&resultPredicate){setDraft(draftOf(resultPredicate));setFilterSplits(resultSplits);} + if(step!=='filter'&&baseResultPredicate){setDraft(draftOf(baseResultPredicate));setFilterSplits(resultSplits);} setEditorOpen(true); } async function applyPopupSearch(nextDraft,predicate,signal){ const result=await api('/explore/run',{method:'POST',body:{predicate,splits:filterSplits},signal}); if(signal.aborted)return; - setAnnotationHold(null);setNotice('');setDraft(nextDraft);setDraftPreview({data:result,loading:false,error:null,key:predicateIdentity({predicate,splits:filterSplits})}); + setViewFilters({});setExcludedEpochs([]);setExportCandidate(null);setAnnotationHold(null);setNotice('');setDraft(nextDraft);setDraftPreview({data:result,loading:false,error:null,key:predicateIdentity({predicate,splits:filterSplits})}); setMatchingNavigation(null);setResultsFromDraft(true);setFocused(null);setDestination(null);setStep('results');setEditorOpen(false); storePreset({name:name==='Metadata selection'?predicateSummary(predicate):name,predicate,splits:filterSplits,matched_count:result.matched_count,cell_count:result.last_run.cell_count,lastRunAt:result.last_run.ran_at});projectPresets.reload(); } @@ -208,12 +235,14 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, setFocused(null);setDestination(null); setResultsFromDraft(!applied);setStep('results'); } + function toggleInclusion(epoch,included){if(busy)return;setExcludedEpochs(ids=>toggleSearchInclusion(ids,epoch.epoch_uuid,included));setDestination(null);setExportCandidate(null);} const exportDisabled=!!annotationHold||busy||resultLoading||!!resultError||!resultPreview?.matched_count; function openResultsExport(){ if(step==='tree'){setFocused(null);setResultsFromDraft(false);setStep('results');prepareDestination('export',false);} else prepareDestination('export'); } const resultActions=[ + {label:'Edit predicate',icon:Filter,disabled:busy,run:editSearch}, {label:'Save query preset',icon:Save,disabled:busy||resultLoading||!!resultError||!resultPreview||!resultPredicate,run:saveQueryPreset}, {label:'Save selection revision',icon:Save,disabled:!!annotationHold||busy||resultLoading||!!resultError||!resultPreview,run:()=>saveRevision(resultsFromDraft?'filter':'tree')}, {label:'Search presets',icon:History,disabled:busy,run:()=>{setStep('filter');setFocused(null);setDestination(null);}}, @@ -221,7 +250,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, ]; return
    {presetDialog&&setPresetDialog(null)} onSaved={savedQueryPreset} onRefresh={projectPresets.reload}/>} - {editorOpen&&setEditorOpen(false)}/>} + {editorOpen&&setEditorOpen(false)}/>} {step==='filter'?(
    SOURCE RECORDINGS

    {focused?'Inspect selected epoch':step==='results'?'Epoch browser':step==='tree'?'Tree view · advanced':'Search presets'}

    {step==='tree'&&} @@ -232,37 +261,24 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, {onExit&&}
    ):
    {step==='tree'?'Design tree':'Search results'}
    - {step==='tree'&&} - {step==='tree'&&} - {step==='tree'&&<>} -
    {step==='results'&&
    }
    } + + + +
    } + {hasViewFilter&&resultError&&
    {resultError}
    } {error&&
    {error}
    } {annotationHold&&
    Annotations changed. The current epoch page and selection are retained; refresh results before exporting or applying this query.
    } {notice&&
    {notice}
    } {initialCandidateLoading?
    Loading saved candidate…
    :initialRevisionId&&!applied&&!departedInitial?
    The saved candidate could not be loaded.
    :step==='filter'&&!focused?usePreset(item,'edit')} onPin={item=>usePreset(item,'pin')}/>:step==='results'?
    - {destination&&applied&&!resultsFromDraft&&{setDestination(null);onChange?.();}} onApplied={onProtocolApplied} onChanged={()=>onChange?.()}/>} - {resultError&&
    {resultError}
    } - {!resultLoading&&!resultError&&resultPreview&&resultPredicate?{if(!annotationHold&&!busy&&!resultLoading&&!resultError)prepareDestination('tree');}} key={resultPreview.tree_revision} predicate={resultPredicate} splits={resultSplits} preview={resultPreview} session={matchingNavigation} onSession={setMatchingNavigation} onAnnotationsChanged={annotationsChanged} onTagFilter={predicate=>{setDraft(draftOf(predicate));setActivePreset(null);setEditorOpen(true);}} onRefresh={refreshResults}/>:} + {destination&&exportCandidate&&{setDestination(null);onChange?.();}} onApplied={onProtocolApplied} onChanged={()=>onChange?.()}/>} + {!hasViewFilter&&resultError&&
    {resultError}
    } + {!resultLoading&&!resultError&&resultPreview&&resultPredicate?searchEpochInclusion(epoch,excludedEpochs)} onToggleInclusion={toggleInclusion} toolbarTarget={epochToolbarTarget} viewFilters={viewFilters} onViewFilters={changeViewFilters} filterRevision={revision} filterDisabled={busy||!!annotationHold} onExport={openResultsExport} exportDisabled={exportDisabled} designDisabled={!!annotationHold||busy||resultLoading||!!resultError} actions={resultActions} onDesign={()=>{if(!annotationHold&&!busy&&!resultLoading&&!resultError)prepareDestination('tree');}} key={resultPreview.tree_revision} predicate={resultPredicate} splits={resultSplits} preview={resultPreview} session={matchingNavigation} onSession={setMatchingNavigation} onAnnotationsChanged={annotationsChanged} onTagFilter={predicate=>{setDraft(draftOf(predicate));setActivePreset(null);setEditorOpen(true);}} onRefresh={refreshResults}/>:}
    :<> -
    {tree.data?`${tree.data===applied?.preview?'Recorded selection':'Preview'}: ${number(tree.data.matched_count)} / ${number(tree.data.total_source)} source epochs`:'Evaluating applied filter…'}Saved revision {applied?.revision_uuid.slice(0,8)} · {number(applied?.recipe.epoch_count ?? applied?.recipe.epochs?.length)} epochs recorded - {changedFilter&&Unapplied filter edits}{changedTree||changedMembership?<>{changedMembership?'Current preview differs from saved revision':'Tree changes are a draft'}:Filter & tree recorded} - {treeDiff&&(treeDiff.added||treeDiff.removed||treeDiff.changed)?Current data: +{treeDiff.added} / −{treeDiff.removed} / {treeDiff.changed} changed:null} -
    - {!focused&&applied&&
    Tree layout
    } - {!focused&&applied&&} - {if(name==='tree')setGroupingWidth(value);}} - onResizeCommit={(name,value)=>{if(name==='tree')try{localStorage.setItem('workspace.explorer.groupingWidth',String(value));}catch{}}} - tree={tree.data?:setGeneration(value=>value+1)}/>} - detail={!focused?setGeneration(value=>value+1)}>{tree.data&&applied&&setGeneration(value=>value+1)} onMetadata={setPagedInfo} onStatus={setPagedStatus} onSelectEpoch={uuid=>{setMatchingNavigation({revision:tree.data.tree_revision,focused:uuid});setFocused(null);setResultsFromDraft(false);setStep('results');}}/>}:
    {epoch.reload();setGeneration(value=>value+1);}}>{epoch.data?.epoch_uuid===focused&&focusedInScope&&<> -
    APPLIED FILTER · READ-ONLY EPOCH

    {datedCellLabel(epoch.data)}

    Epoch {epoch.data.epoch_number ?? '—'} within block · {epoch.data.start_time?.split(' ')[1]?.slice(0,8)}

    - -
    Open a different workspace

    {matches.length?'A predefined workspace has its own saved query. Switching below does not carry this explorer filter, and its inspection/export scope may be broader.':'No predefined workspace matches this recording. The applied explorer scope remains read-only.'}

    - {matches.map(protocol=>)} -
    - - {['cell','group','block'].map(level=>
    {level[0].toUpperCase()+level.slice(1)} source metadata
    )} - }
    }/> + setGeneration(value=>value+1)}>{displayTree.data&&applied&&{if(name==='tree')setGroupingWidth(value);},onResizeCommit:(name,value)=>{if(name==='tree')try{localStorage.setItem('workspace.explorer.groupingWidth',String(value));}catch{}}}} + builder={{catalogData:displayTree.data.catalog,value:splits.split(',').filter(Boolean),onChange:changeSplits,preview:pagedInfo&&pagedInfo.split_order?.join(',')===splits?{...displayTree.data.tree,...pagedInfo,count:displayTree.data.matched_count}:displayTree.data.tree,loading:displayTree.loading||pagedStatus.loading,error:displayTree.error||pagedStatus.error}} + columnTree={{inclusionForEpoch:epoch=>searchEpochInclusion(epoch,excludedEpochs),onToggleInclusion:toggleInclusion,actionsDisabled:busy,predicate:resultPredicate,splits,revision:`${viewRevision}:${generation}`,initialNavigation:treeNavigation,onNavigationChange:setTreeNavigation,expectedRevision:displayTree.data.tree_revision,onRefreshPreview:()=>setGeneration(value=>value+1),onMetadata:setPagedInfo,onStatus:setPagedStatus,onSelectEpoch:uuid=>{setMatchingNavigation({revision:displayTree.data.tree_revision,focused:uuid});setFocused(null);setResultsFromDraft(false);setStep('results');}}}/>} }
    ; diff --git a/workspace-app/src/components/ProtocolViewFilter.jsx b/workspace-app/src/components/ProtocolViewFilter.jsx index affaaaf..4285df0 100644 --- a/workspace-app/src/components/ProtocolViewFilter.jsx +++ b/workspace-app/src/components/ProtocolViewFilter.jsx @@ -11,5 +11,5 @@ export default function ProtocolViewFilter({filters={},onChange,revision,disable function toggle(){if(!open){try{const restored=readTagRules(filters);setLogic(restored.mode);setRules(restored.rules);setMode(filters.tag||filters.tag_predicate?'rules':filters.tagged?'tagged':'all_epochs');setError('');}catch(e){setError(e.message);}}setOpen(value=>!value);} function update(index,changes){setRules(old=>old.map((rule,i)=>i===index?{...rule,...changes}:rule));} function apply(event){event.preventDefault();try{const next=clearTagFilters(filters);if(mode==='rules')next.tag_predicate=JSON.stringify(compileTagRules(logic,rules));if(mode==='tagged')next.tagged='true';setOpen(false);onChange(next);}catch(e){setError(e.message);}} - return
    {open&&
    Filter this protocol

    Applies to both epoch views and exports.

    {mode==='rules'&&<>
    of these rules match
    {rules.map((rule,index)=>
    {update(index,{value:e.target.value});setQuery(e.target.value);}} placeholder="Tag name" list="protocol-view-tag-options"/>
    )}{(options.data?.tags||[]).map(item=>

    Tags match whole saved names. Shared tags include inherited cell tags.

    }{error&&

    {error}

    }
    {active&&}
    }
    ; + return
    {open&&
    Filter this view

    Applies to both epoch views and exports.

    {mode==='rules'&&<>
    of these rules match
    {rules.map((rule,index)=>
    {update(index,{value:e.target.value});setQuery(e.target.value);}} placeholder="Tag name" list="protocol-view-tag-options"/>
    )}{(options.data?.tags||[]).map(item=>

    Tags match whole saved names. Shared tags include inherited cell tags.

    }{error&&

    {error}

    }
    {active&&}
    }
    ; } diff --git a/workspace-app/src/epochViewerArchitecture.test.js b/workspace-app/src/epochViewerArchitecture.test.js new file mode 100644 index 0000000..395952f --- /dev/null +++ b/workspace-app/src/epochViewerArchitecture.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +test('protocol and search adapters delegate their entire viewer assembly to one shared renderer',async()=>{ + for(const adapter of ['Inspector','MatchingEpochs','MetadataExplorer']){ + const source=await readFile(new URL(`./components/${adapter}.jsx`,import.meta.url),'utf8'); + assert.match(source,/{ + const explorer=await readFile(new URL('./components/MetadataExplorer.jsx',import.meta.url),'utf8'); + assert.match(explorer,/{ const rules=[{scope:'effective',comparison:'is',value:' selected '},{scope:'cell',comparison:'is_not',value:'artifact'}]; for(const mode of ['all','any']){ @@ -16,3 +16,13 @@ test('clearing tags preserves other protocol filters and shows readable multi-ru assert.ok(filters.tag_predicate); assert.throws(()=>compileTagRules('all',[{scope:'effective',comparison:'is',value:''}])); }); + +test('search local tag filters narrow the base query, and clearing restores that same query',()=>{ + const base={all:[{field:'protocol',operator:'eq',value:'VariableHistoryNoiseCurInject'}]}; + const rules=compileTagRules('any',[{scope:'epoch',comparison:'is',value:'reviewed'},{scope:'cell',comparison:'is_not',value:'artifact'}]); + const filters={tag_predicate:JSON.stringify(rules)}; + assert.deepEqual(predicateWithTagFilters(base,filters),{all:[base,rules]}); + assert.equal(predicateWithTagFilters(base,clearTagFilters(filters)),base); + assert.deepEqual(base,{all:[{field:'protocol',operator:'eq',value:'VariableHistoryNoiseCurInject'}]}); + assert.deepEqual(predicateWithTagFilters(base,{tagged:'true'}),{all:[base,{field:'annotations/effective/tags',operator:'ne',value:[]}]}); +}); diff --git a/workspace-app/src/searchInclusion.js b/workspace-app/src/searchInclusion.js new file mode 100644 index 0000000..b3998e1 --- /dev/null +++ b/workspace-app/src/searchInclusion.js @@ -0,0 +1,29 @@ +// Search exclusions are local to this viewer. Candidate destinations all receive +// the same constrained query, while the browsable base query stays unchanged. +function nodeCount(predicate){ + if(Array.isArray(predicate.all))return 1+predicate.all.reduce((count,node)=>count+nodeCount(node),0); + if(Array.isArray(predicate.any))return 1+predicate.any.reduce((count,node)=>count+nodeCount(node),0); + return predicate.not?1+nodeCount(predicate.not):1; +} +function nodeDepth(predicate){ + const children=Array.isArray(predicate.all)?predicate.all:Array.isArray(predicate.any)?predicate.any:predicate.not?[predicate.not]:[]; + return 1+children.reduce((depth,node)=>Math.max(depth,nodeDepth(node)),0); +} +export function searchInclusionPredicate(predicate,excluded=[]){ + const ids=[...new Set(excluded)].sort(); + if(!ids.length)return predicate; + // The backend caps each comparison literal at 4096 encoded characters. + // Separate bounded UUID clauses preserve exact membership for large searches. + const clauses=[]; + for(let offset=0;offset128)throw new Error('This search and its exclusions exceed the query size limit. Simplify the search criteria or include more epochs before exporting.'); + if(nodeDepth(result)>8)throw new Error('This search is nested too deeply to add exclusions. Simplify the search criteria before exporting.'); + return result; +} +export function searchEpochInclusion(epoch,excluded=[]){ + return {...epoch,curation:{...epoch.curation,included:!excluded.includes(epoch.epoch_uuid)}}; +} +export function toggleSearchInclusion(excluded,uuid,included){ + return included?excluded.filter(id=>id!==uuid):[...new Set([...excluded,uuid])]; +} diff --git a/workspace-app/src/searchInclusion.test.js b/workspace-app/src/searchInclusion.test.js new file mode 100644 index 0000000..3e605c6 --- /dev/null +++ b/workspace-app/src/searchInclusion.test.js @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {searchInclusionPredicate,searchEpochInclusion,toggleSearchInclusion} from './searchInclusion.js'; +import {snapshotExplorerState} from './workspaceNavigation.js'; +test('local exclusions constrain every candidate destination without changing browsable predicate or protocol curation',()=>{ + const base={field:'protocol',operator:'eq',value:'example'}; + const row={epoch_uuid:'one',curation:{included:false,tags:['existing']}}; + assert.equal(searchEpochInclusion(row,[]).curation.included,true); + const excluded=toggleSearchInclusion([],'one',false); + assert.equal(searchEpochInclusion(row,excluded).curation.included,false); + assert.deepEqual(searchInclusionPredicate(base,excluded),{all:[base,{field:'epoch',operator:'not_in',value:['one']}]}); + assert.deepEqual(base,{field:'protocol',operator:'eq',value:'example'}); + assert.deepEqual(row.curation,{included:false,tags:['existing']}); + assert.equal(searchInclusionPredicate(base,toggleSearchInclusion(excluded,'one',true)),base); + assert.deepEqual(toggleSearchInclusion(excluded,'one',false),['one']); +}); +test('search session retains local exclusions independently of saved revisions',()=>{ + assert.deepEqual(snapshotExplorerState({excludedEpochs:['one'],draft:{all:[]}}).excludedEpochs,['one']); +}); +test('large UUID exclusion sets stay inside backend literal limits without adding depth to all queries',()=>{ + const base={all:[{field:'protocol',operator:'eq',value:'example'}]}; + const ids=Array.from({length:250},(_,index)=>`00000000-0000-4000-8000-${String(index).padStart(12,'0')}`); + const predicate=searchInclusionPredicate(base,ids); + assert.equal(predicate.all[0],base.all[0]); + assert.equal(predicate.all.length,5); + const clauses=predicate.all.slice(1); + for(const clause of clauses)assert.ok(JSON.stringify(clause.value).length<4096); + assert.deepEqual(clauses.flatMap(clause=>clause.value),ids); + assert.equal(base.all.length,1); + assert.throws(()=>searchInclusionPredicate({all:Array.from({length:127},()=>base.all[0])},ids),/query size limit/); +}); + +test('deep queries fail clearly before saving a candidate while existing all groups retain their depth',()=>{ + const leaf={field:'protocol',operator:'eq',value:'example'}; + const nested=Array.from({length:7}).reduce(node=>({not:node}),leaf); + assert.throws(()=>searchInclusionPredicate(nested,['one']),/nested too deeply/); + const base={all:[Array.from({length:6}).reduce(node=>({not:node}),leaf)]}; + assert.equal(searchInclusionPredicate(base,['one']).all[0],base.all[0]); +}); From bd034bbd8508efd34b8b53d490ec4ddcd41cb09d Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:57:14 -0700 Subject: [PATCH 3/8] Guard unselected pinned epochs and exercise shared viewer rendering --- workspace-app/src/components/Inspector.jsx | 2 +- .../src/epochViewerRendering.test.js | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 workspace-app/src/epochViewerRendering.test.js diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 21d99b9..75a4587 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -303,7 +303,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi resource={{scope:id,...epoch,loading:!epoch.error&&(!!pendingNavigation||(!!focused&&(epoch.loading||!focusedEpoch))),retry:epoch.reload}} epoch={focusedEpoch} targets={targets} navigation={{position:focusedPageIndex<0?-1:offset+focusedPageIndex,total:rows.data?.total||0,loading:!!pendingNavigation||rows.loading,disabled:busy,onMove:moveEpoch}} traceRevision={revision} inclusion={{disabled:busy,scope:'pinned dataset',onToggle:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}} detailDisabled={busy} onQC={onQC} tags={tagEntry} - detailExtras={<> {busy&&

    {operationMessage}

    } + detailExtras={focusedEpoch&&<> {busy&&

    {operationMessage}

    }
    Dataset-only tags: {(focusedEpoch.curation?.tags||[]).map(t=>)} {!focusedEpoch.curation?.tags?.length&&No tags} diff --git a/workspace-app/src/epochViewerRendering.test.js b/workspace-app/src/epochViewerRendering.test.js new file mode 100644 index 0000000..d744981 --- /dev/null +++ b/workspace-app/src/epochViewerRendering.test.js @@ -0,0 +1,41 @@ +import test,{before,after} from 'node:test'; +import assert from 'node:assert/strict'; +import {fileURLToPath} from 'node:url'; +import {createElement} from 'react'; +import {renderToString} from 'react-dom/server'; +import {createServer} from 'vite'; +import react from '@vitejs/plugin-react'; + +let server; +before(async()=>{ + server=await createServer({root:fileURLToPath(new URL('..',import.meta.url)),configFile:false,plugins:[react()],server:{middlewareMode:true,hmr:false},appType:'custom',logLevel:'error'}); +}); +after(async()=>{await server?.close();}); +function render(Component,props){ + const previous=console.error; + // Layout effects intentionally wait for the browser; SSR still exercises + // adapter render expressions (the null-epoch crash happened before effects). + console.error=(message,...args)=>{if(!String(message).startsWith('Warning: useLayoutEffect does nothing on the server'))previous(message,...args);}; + try{return renderToString(createElement(Component,props));}finally{console.error=previous;} +} +test('opening a pinned inspector before any epoch is focused renders its empty state without dereferencing curation',async()=>{ + const {default:Inspector}=await server.ssrLoadModule('/src/components/Inspector.jsx'); + const html=render(Inspector,{protocol:{definition:{protocol_uuid:'fixture',name:'Variable History Noise'},cells:[]},filters:{},revision:0}); + assert.match(html,/Choose an epoch/); + assert.match(html,/Epoch list controls/); + assert.doesNotMatch(html,/Dataset-only tags:/); + assert.doesNotMatch(html,/Include in analysis/); +}); +test('opening Search results before any epoch is focused uses the same safe viewer empty state',async()=>{ + const {default:MatchingEpochs}=await server.ssrLoadModule('/src/components/MatchingEpochs.jsx'); + const html=render(MatchingEpochs,{predicate:{all:[]},splits:'date,cell',preview:{tree_revision:'fixture',catalog:{fields:[]}},viewFilters:{},onViewFilters:()=>{}}); + assert.match(html,/Choose an epoch/); + assert.match(html,/Epoch list controls/); + assert.doesNotMatch(html,/Include in analysis/); +}); +test('shared viewer accepts no selected epoch and no source-specific extras',async()=>{ + const {default:EpochViewer}=await server.ssrLoadModule('/src/components/EpochViewer.jsx'); + const html=render(EpochViewer,{layout:{sizes:{columns:'minmax(0,1fr)',metadata:300},treeOpen:false,metadataOpen:false,onResize:()=>{}},epoch:null}); + assert.match(html,/Choose an epoch/); + assert.doesNotMatch(html,/Include in analysis/); +}); From fa9eb0808aeac177a244e29dae69c67038abfc67 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:01:55 -0700 Subject: [PATCH 4/8] Share recorded scientific context and remove obsolete tree UI --- workspace-app/src/components/EpochViewer.jsx | 3 +- workspace-app/src/components/Inspector.jsx | 92 ++----------------- .../src/components/ScientificContext.jsx | 35 +++++++ .../src/epochViewerRendering.test.js | 11 +++ 4 files changed, 56 insertions(+), 85 deletions(-) create mode 100644 workspace-app/src/components/ScientificContext.jsx diff --git a/workspace-app/src/components/EpochViewer.jsx b/workspace-app/src/components/EpochViewer.jsx index 95c0472..ee78eca 100644 --- a/workspace-app/src/components/EpochViewer.jsx +++ b/workspace-app/src/components/EpochViewer.jsx @@ -1,3 +1,4 @@ +import ScientificContext from './ScientificContext.jsx'; import {EpochBrowserToolbar,EpochNavigation} from './EpochBrowserChrome.jsx'; import EpochBrowserLayout from './EpochBrowserLayout.jsx'; import EpochTreePane from './EpochTreePane.jsx'; @@ -28,6 +29,6 @@ export default function EpochViewer({className='epoch-inspector-mode',ariaLabel= :{!layout.metadataOpen&&{tags}}} detail={designMode?:detail} - metadata={metadata&&}/> + metadata={metadata&&}/>}/>
    ; } diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 75a4587..e208721 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -1,12 +1,12 @@ import EpochViewer from './EpochViewer.jsx'; import SelectionMaskDialog from './SelectionMaskDialog.jsx'; import {NavigationLoadingProvider} from './NavigationLoading.jsx'; -import {advanceEpochIntent,epochIntentAt,epochAtIntent} from '../epochNavigationIntent.js'; +import {advanceEpochIntent, epochIntentAt, epochAtIntent} from '../epochNavigationIntent.js'; import {SourceEligibilityNotice} from './Common.jsx'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ChevronDown, ChevronRight, GitBranch, Check, X, MessageCircle, ArrowLeft, ArrowRight, Activity, Eye, Info, PanelRightClose, PanelRightOpen, Upload, Download, FileJson } from 'lucide-react'; -import { api, useResource, useEpochResource, useEpochPrefetch, number, humanize, resolveCurationTargets } from '../api.js'; -import { Status, Metadata, Badge, Empty } from './Common.jsx'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {GitBranch, Check, X, Eye, Upload, Download, FileJson} from 'lucide-react'; +import {api, useResource, useEpochResource, useEpochPrefetch, number, humanize, resolveCurationTargets} from '../api.js'; +import {Badge} from './Common.jsx'; import EpochConnections from './EpochConnections.jsx'; import './InspectorPolish.css'; import './MatlabMaskImport.css'; @@ -15,89 +15,13 @@ import AnnotationTags from './AnnotationTags.jsx'; import TagExchangeControls from './TagExchangeControls.jsx'; import './Inspector.css'; -import {inspectionSearches,indexInspectionTree,focusAfterTreeSelection,curationMatchesCellFocus} from '../inspectionScope.js'; -import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; +import {inspectionSearches, curationMatchesCellFocus} from '../inspectionScope.js'; import {datedCellLabel} from '../recordingIdentity.js'; import {restoredEpochFocus} from '../workspaceNavigation.js'; -import {boundedTreePage,inspectionTreeFocus} from '../boundedTree.js'; import Trace from './TraceViewer.jsx'; -import {inspectorPaneSizes,epochShortcutDirection,resourceForPath} from '../inspectorInteraction.js'; +import {inspectorPaneSizes, epochShortcutDirection, resourceForPath} from '../inspectorInteraction.js'; export {Trace}; -function TreePageControls({page,onPage,kind}){ - if(page.total<=60&&!page.pinnedOutside)return null; - return
    {page.total?page.offset+1:0}–{page.end} of {number(page.total)} {kind}
    {page.pinnedOutside&&Focused item also shown below this page.}
    ; -} -export function Branch({node,onFocus,selected,depth=0,cellBranches=null,parentField=null,treeFocus}) { - const selectedBranch=treeFocus?.branches.has(node) || false; - const [open,setOpen] = useState(selectedBranch); - const [offset,setOffset] = useState(0); - const inFocusedCell=cellBranches?.has(node) || false; - useEffect(()=>{if(selectedBranch)setOpen(true);},[selectedBranch]); - const children = node.children || []; - // Legacy UUID-only leaves are converted only for the visible page. - const leafValues=node.epochs || node.epoch_uuids || []; - const pin=children.length?treeFocus?.childOf.get(node):selectedBranch?(node.epochs?treeFocus?.epoch:selected):null; - const page=boundedTreePage(children.length?children:leafValues,offset,60,pin); - return
    - - {open &&
    - {children.length>0&&node.field_label&&
    {readableField(node.field_label,node.field)}
    } - - {children.length?page.items.map(n=>):page.items.map(item=>{ - const e=typeof item==='string'?{epoch_uuid:item}:item; - return ; - })} -
    } -
    ; -} -function InspectionTree({tree,onFocus,selected,cellBranches,treeFocus}){ - const [offset,setOffset]=useState(0); - const nodes=tree.children?.length?tree.children:tree.tree?.children?.length?tree.tree.children:tree.groups?.length?tree.groups:(tree.epochs||tree.epoch_uuids?[tree]:[]); - const pin=nodes.length===1&&nodes[0]===tree?tree:treeFocus?.childOf.get(tree); - const page=boundedTreePage(nodes,offset,60,pin); - return <>{page.items.map(node=>)}; -} - -function recordedValue(value) { - if (value === undefined || value === null) return 'Not recorded'; - if (value === '[]' || (Array.isArray(value) && value.length === 0)) return '[] (empty recorded field)'; - return typeof value === 'object' ? JSON.stringify(value) : String(value); -} -function ScientificContext({epoch}) { - const additions = epoch.metadata?.group?.properties?.externalSolutionAdditions; - const historyProtocol=/VariableHistoryNoiseCurInject/.test(epoch.protocol_name || ''); - const controlFlag=Object.hasOwn(epoch.parameters || {},'isControl')?epoch.parameters.isControl:epoch.parameters?.controlMode; - const historyControl=historyProtocol&&[1,true].includes(controlFlag); - const historyPairs=historyProtocol?['history1','history2','target'].filter(key=>Array.isArray(epoch.parameters?.[key])&&epoch.parameters[key].length===2):[]; - const conditions = [ - ['currentMean','Current mean',''], - ['currentSD','Current SD',''], - ['frequencyCutoff','Frequency cutoff',''], - ['stimTime','Stimulus duration',''], - ].filter(([key])=>epoch.parameters?.[key] !== undefined && epoch.parameters[key] !== null); - return
    -
    {humanize(epoch.cell_type) || 'Type not recorded'}{epoch.source_filename || 'Source not recorded'}
    -
    Group label: {recordedValue(epoch.group_label)}External additions: {recordedValue(additions)}
    - {historyControl&&

    Control epoch · target only; history settings were recorded but not delivered.

    } - {historyPairs.length>0&&
    {historyPairs.map(key=>
    {key==='target'?'Target':key==='history1'?'History 1':'History 2'}{historyControl&&key!=='target'?' · configured only':''}{recordedValue(epoch.parameters[key][0])} / {recordedValue(epoch.parameters[key][1])}Recorded mean / SD
    )}
    } - {conditions.length>0&&
    {conditions.map(([key,label,units])=>
    - {label}{recordedValue(epoch.parameters[key])}{units && ` ${units}`} -
    )}
    } -
    Block {epoch.block_start_time?.split(' ')[1] || 'time not recorded'} · acquisition details
    -
    Epoch block started{recordedValue(epoch.block_start_time)}
    -
    Epoch block ended{recordedValue(epoch.block_end_time)}
    -
    Acquisition protocol · source value{recordedValue(epoch.protocol_name)}
    -

    Group labels and solution fields are separate source records. Empty fields do not establish control or wash.

    -
    ; -} - function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revision,onChange,onBack,onImport,onStores,onExport,splitRecipe=['date','cell','block'],onSplitChange,initialNavigation=null,onSessionChange,onQC,onTagFilter,onFilterChange,toolbarTarget=null}) { const id=protocol.definition.protocol_uuid; const requestedCellFocus=filters?.cell_uuid&&filters.cell_uuid!==cellScope?null:(cellScope || null); @@ -314,7 +238,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi - } metadata={{selectionCell:cellTagRequest,selectedEpochs:targets,onClearSelection:()=>setTargets([]),catalog:metadataCatalog,onClose:()=>toggleMetadata(false),context:focusedEpoch&&,connections:focusedEpoch&&}}/>; + } metadata={{selectionCell:cellTagRequest,selectedEpochs:targets,onClearSelection:()=>setTargets([]),catalog:metadataCatalog,onClose:()=>toggleMetadata(false),connections:focusedEpoch&&}}/>; } export default function Inspector(props){return ;} diff --git a/workspace-app/src/components/ScientificContext.jsx b/workspace-app/src/components/ScientificContext.jsx new file mode 100644 index 0000000..b0ea77d --- /dev/null +++ b/workspace-app/src/components/ScientificContext.jsx @@ -0,0 +1,35 @@ +import {humanize} from '../api.js'; + +function recordedValue(value) { + if (value === undefined || value === null) return 'Not recorded'; + if (value === '[]' || (Array.isArray(value) && value.length === 0)) return '[] (empty recorded field)'; + return typeof value === 'object' ? JSON.stringify(value) : String(value); +} +export default function ScientificContext({epoch}) { + const additions = epoch.metadata?.group?.properties?.externalSolutionAdditions; + const historyProtocol=/VariableHistoryNoiseCurInject/.test(epoch.protocol_name || ''); + const controlFlag=Object.hasOwn(epoch.parameters || {},'isControl')?epoch.parameters.isControl:epoch.parameters?.controlMode; + const historyControl=historyProtocol&&[1,true].includes(controlFlag); + const historyPairs=historyProtocol?['history1','history2','target'].filter(key=>Array.isArray(epoch.parameters?.[key])&&epoch.parameters[key].length===2):[]; + const conditions = [ + ['currentMean','Current mean',''], + ['currentSD','Current SD',''], + ['frequencyCutoff','Frequency cutoff',''], + ['stimTime','Stimulus duration',''], + ].filter(([key])=>epoch.parameters?.[key] !== undefined && epoch.parameters[key] !== null); + return
    +
    {humanize(epoch.cell_type) || 'Type not recorded'}{epoch.source_filename || 'Source not recorded'}
    +
    Group label: {recordedValue(epoch.group_label)}External additions: {recordedValue(additions)}
    + {historyControl&&

    Control epoch · target only; history settings were recorded but not delivered.

    } + {historyPairs.length>0&&
    {historyPairs.map(key=>
    {key==='target'?'Target':key==='history1'?'History 1':'History 2'}{historyControl&&key!=='target'?' · configured only':''}{recordedValue(epoch.parameters[key][0])} / {recordedValue(epoch.parameters[key][1])}Recorded mean / SD
    )}
    } + {conditions.length>0&&
    {conditions.map(([key,label,units])=>
    + {label}{recordedValue(epoch.parameters[key])}{units && ` ${units}`} +
    )}
    } +
    Block {epoch.block_start_time?.split(' ')[1] || 'time not recorded'} · acquisition details
    +
    Epoch block started{recordedValue(epoch.block_start_time)}
    +
    Epoch block ended{recordedValue(epoch.block_end_time)}
    +
    Acquisition protocol · source value{recordedValue(epoch.protocol_name)}
    +

    Group labels and solution fields are separate source records. Empty fields do not establish control or wash.

    +
    ; +} + diff --git a/workspace-app/src/epochViewerRendering.test.js b/workspace-app/src/epochViewerRendering.test.js index d744981..6e0f504 100644 --- a/workspace-app/src/epochViewerRendering.test.js +++ b/workspace-app/src/epochViewerRendering.test.js @@ -39,3 +39,14 @@ test('shared viewer accepts no selected epoch and no source-specific extras',asy assert.match(html,/Choose an epoch/); assert.doesNotMatch(html,/Include in analysis/); }); +test('common recorded scientific context preserves source facts and marks configured-only control history',async()=>{ + const {default:ScientificContext}=await server.ssrLoadModule('/src/components/ScientificContext.jsx'); + const html=render(ScientificContext,{epoch:{protocol_name:'example.VariableHistoryNoiseCurInject',cell_type:'On Parasol',source_filename:'fixture.h5',group_label:'[]',parameters:{isControl:1,history1:[0,675],history2:[0,50],target:[0,200]},metadata:{group:{properties:{externalSolutionAdditions:[]}}}}}); + assert.match(html,/Recording and condition context/); + assert.match(html,/Control epoch · target only/); + const text=html.replace(//g,'').replace(/<[^>]*>/g,''); + assert.match(text,/History 1 · configured only/); + assert.match(html,/\[\] \(empty recorded field\)/); + assert.match(text,/0 \/ 675/); + assert.match(html,/fixture\.h5/); +}); From 47f87f9f1adc373523869ac624c35fdfe566b4e9 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:02:20 -0700 Subject: [PATCH 5/8] Normalize shared scientific context file ending --- workspace-app/src/components/ScientificContext.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/workspace-app/src/components/ScientificContext.jsx b/workspace-app/src/components/ScientificContext.jsx index b0ea77d..5f1ef09 100644 --- a/workspace-app/src/components/ScientificContext.jsx +++ b/workspace-app/src/components/ScientificContext.jsx @@ -32,4 +32,3 @@ export default function ScientificContext({epoch}) {

    Group labels and solution fields are separate source records. Empty fields do not establish control or wash.

; } - From 2b1ddab381daf57a6e0e53960608230bce4ca3cc Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:21:31 -0700 Subject: [PATCH 6/8] Fix shared tree designer navigation and preserve quick edits --- workspace-app/src/columnTreeNavigation.js | 6 ++++ .../src/columnTreeNavigation.test.js | 8 ++++- workspace-app/src/components/ColumnTree.jsx | 4 +-- workspace-app/src/components/PagedTree.jsx | 2 +- workspace-app/src/components/TreeBuilder.jsx | 29 ++++++++++--------- workspace-app/src/jointGrouping.js | 10 +++++++ workspace-app/src/jointGrouping.test.js | 10 ++++++- 7 files changed, 50 insertions(+), 19 deletions(-) diff --git a/workspace-app/src/columnTreeNavigation.js b/workspace-app/src/columnTreeNavigation.js index 70de2ca..e9e1c81 100644 --- a/workspace-app/src/columnTreeNavigation.js +++ b/workspace-app/src/columnTreeNavigation.js @@ -14,3 +14,9 @@ export function columnSelectionNeedsAnchor(columns,selected,pending=false){ const leaf=columns.at(-1); return pending||leaf?.kind!=='epochs'||!leaf.epochs?.some(epoch=>epoch.epoch_uuid===selected); } + +// Closing a column returns to its parent page without selecting scientific data. +export function columnBranchNavigation(page,item,expandedKey){ + const opening=expandedKey!==item.key; + return {opening,path:opening?item.path:page.path,offset:opening?0:page.offset}; +} diff --git a/workspace-app/src/columnTreeNavigation.test.js b/workspace-app/src/columnTreeNavigation.test.js index bdfd700..925b65e 100644 --- a/workspace-app/src/columnTreeNavigation.test.js +++ b/workspace-app/src/columnTreeNavigation.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor} from './columnTreeNavigation.js'; +import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation} from './columnTreeNavigation.js'; test('selected epoch reconstructs ancestor pages beyond the first sixty branches',()=>{ const page={revision:'exact',path:['date-key','cell-key','block-key'],ancestors:[{parent_offset:120},{parent_offset:60},{parent_offset:180}]}; @@ -25,3 +25,9 @@ test('external selections already present need no request, while pending or othe assert.equal(columnSelectionNeedsAnchor(columns,'shown',true),true); assert.equal(columnSelectionNeedsAnchor(columns,null),false); }); + +test('closing a column branch returns to its parent page without selecting a descendant',()=>{ + const page={path:['date'],offset:60},cell={key:'cell3',path:['date','cell3']}; + assert.deepEqual(columnBranchNavigation(page,cell,'cell3'),{opening:false,path:['date'],offset:60}); + assert.deepEqual(columnBranchNavigation(page,cell,'cell5'),{opening:true,path:['date','cell3'],offset:0}); +}); diff --git a/workspace-app/src/components/ColumnTree.jsx b/workspace-app/src/components/ColumnTree.jsx index 91469b7..8c20507 100644 --- a/workspace-app/src/components/ColumnTree.jsx +++ b/workspace-app/src/components/ColumnTree.jsx @@ -5,7 +5,7 @@ import {ArrowLeft,ArrowRight,ChevronRight,FolderOpen,Home,Activity,GitBranch} fr import {api,number,duration} from '../api.js'; import {treePageRequest,treeNavigationStart,treeNavigationSnapshot} from '../pagedTreeRequest.js'; import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; -import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor} from '../columnTreeNavigation.js'; +import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation} from '../columnTreeNavigation.js'; import {datedCellLabel} from '../recordingIdentity.js'; import './TreePreview.css'; import './ColumnTree.css'; @@ -96,7 +96,7 @@ export default function ColumnTree(props){ return
{terminal?:depth+1}
{terminal?'Inspect epochs':readableField(field?.label,field?.field)}{number(page.total)} {terminal?'epochs':'groups'} · {number(page.selection?.count??page.total_epochs)} epochs in scope
{if(element)panes.current.set(depth,element);else panes.current.delete(depth);}} onScroll={remember}> - {entries.map((item,index)=>terminal?
{props.onToggleInclusion&&}
:{props.onToggleInclusion&&}
:}{view==='tree'?:Loading column view…}>}; } diff --git a/workspace-app/src/components/TreeBuilder.jsx b/workspace-app/src/components/TreeBuilder.jsx index 30e358a..9d9ac00 100644 --- a/workspace-app/src/components/TreeBuilder.jsx +++ b/workspace-app/src/components/TreeBuilder.jsx @@ -6,7 +6,7 @@ import './TreeBuilder.css'; import { reorderIds } from '../ordering.js'; import { startPointerDrag } from '../pointerDrag.js'; import {COMMON_TREE_FIELDS,treeFieldLabel,treeFieldHint,treeFieldExamples,treeFieldMatches,groupingFieldRank} from '../treeFieldPresentation.js'; -import {jointDefinition,shortFieldLabel} from '../jointGrouping.js'; +import {jointDefinition,shortFieldLabel,uncombineLevel} from '../jointGrouping.js'; import JointGroupingEditor from './JointGroupingEditor.jsx'; const categories = ['Common', 'Parameters', 'Combinations', 'Conditions', 'Suggested', 'All']; @@ -50,15 +50,14 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query const [dragging,setDragging] = useState(null); const [dropSpot,setDropSpot] = useState(null); const [announcement,setAnnouncement] = useState(''); - const [copyMessage,setCopyMessage]=useState(null); + const [copyMessage,setCopyMessage]=useState(null),[layoutError,setLayoutError]=useState(''); const orderKey = JSON.stringify(order), valueKey = JSON.stringify(value); useEffect(()=>setCopyMessage(null),[orderKey]); useEffect(()=>setOrder(JSON.parse(valueKey)),[valueKey]); - useEffect(()=>{ - if(orderKey===valueKey)return; - const timer=setTimeout(()=>onChange(JSON.parse(orderKey)),250); - return()=>clearTimeout(timer); - },[orderKey,valueKey,onChange]); + function changeOrder(next){ + const updated=typeof next==='function'?next(currentOrder.current):next; + currentOrder.current=updated;setOrder(updated);onChange(updated); + } const matches = useMemo(()=>{ return fields.filter(field=>treeFieldMatches(field,{order,category,suggestions,search})) .sort((a,b)=>category==='Common'?COMMON_TREE_FIELDS.indexOf(a.id)-COMMON_TREE_FIELDS.indexOf(b.id):category==='Suggested'?suggestions.indexOf(a.id)-suggestions.indexOf(b.id):groupingFieldRank(a)-groupingFieldRank(b) || (a.grouping_priority ?? 100)-(b.grouping_priority ?? 100) || a.label.localeCompare(b.label)); @@ -90,11 +89,11 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query },[active,open,listId]); function add(field) { if(order.length>=8||order.includes(field.id))return; - setOrder(previous=>[...previous,field.id]);setSearch('');setOpen(false);trigger.current?.focus(); + changeOrder(previous=>[...previous,field.id]);setSearch('');setOpen(false);trigger.current?.focus(); } function commitOrder(next, sourceId) { if(next===order || sameOrder(next,order))return; - setOrder(next); + changeOrder(next); setAnnouncement(`${fieldMap.get(sourceId)?.label || sourceId} moved to level ${next.indexOf(sourceId)+1} of ${next.length}.`); } function move(index,offset) { @@ -125,7 +124,7 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query onTarget:target=>setDropSpot(previous=>previous?.id===target?.id&&previous?.placement===target?.placement?previous:target), onDrop:target=>{ const previous=currentOrder.current,next=reorderIds(previous,id,target.id,target.placement); - if(next!==previous){setOrder(next);setAnnouncement(`${fieldMap.get(id)?.label || id} moved to level ${next.indexOf(id)+1} of ${next.length}.`);} + if(next!==previous){changeOrder(next);setAnnouncement(`${fieldMap.get(id)?.label || id} moved to level ${next.indexOf(id)+1} of ${next.length}.`);} }, onCancel:()=>setAnnouncement('Reordering cancelled. Your grouping is unchanged.'), onFinish:()=>{cancelDrag.current=null;setDragging(null);setDropSpot(null);}, @@ -133,7 +132,7 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query } function usePreset(preset) { if(preset.fields.length>8||new Set(preset.fields).size!==preset.fields.length||preset.fields.some(id=>!fieldMap.has(id)))return; - setOrder([...preset.fields]);setOpen(false);setSearch(''); + changeOrder([...preset.fields]);setOpen(false);setSearch(''); } function keys(event) { if(event.key==='Escape'){setOpen(false);event.stopPropagation();return;} @@ -182,15 +181,17 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query {field?.components&&
{field.components.map((key,index)=>{shortFieldLabel(fieldMap.get(key))})}
} {level&&Number.isFinite(level.groups)?`${number(level.groups)} ${level.groups===1?'branch':'branches'}${field?.distinct_count!=null&&level.groups!==field.distinct_count?` · ${number(field.distinct_count)} values`:''}${level.missing_epochs?` · ${number(level.missing_epochs)} not recorded`:''}`:categoryLabel(field?.category) || 'Saved field'}{field?.components?' · all values match':''}
+ {field?.components&&} {showMoveControls&&<> } - +
; })} + {layoutError&&

{layoutError}

} {order.length>1&&} {!order.length&&
All matching epochs in one list
} - {setOrder(next);setOpen(false);setAnnouncement('Combined fields into one split. Every component must match.');}}/> + {changeOrder(next);setOpen(false);setAnnouncement('Combined fields into one split. Every component must match.');}}/>
@@ -202,7 +203,7 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query disabled={preset.fields.length>8||preset.fields.some(id=>!fieldMap.has(id))}>{preset.label})} - +
{catalog.error&&
{catalog.error}
} {catalog.loading&&!catalog.data&&

Reading available fields…

} diff --git a/workspace-app/src/jointGrouping.js b/workspace-app/src/jointGrouping.js index ae6d3d6..a62aeea 100644 --- a/workspace-app/src/jointGrouping.js +++ b/workspace-app/src/jointGrouping.js @@ -35,3 +35,13 @@ export function combineLevels(order, components) { if (next.length > 8) throw new Error('Remove a level first; the tree supports eight levels.'); return next; } + +export function uncombineLevel(order,id){ + const components=jointComponents(id),index=order.indexOf(id); + if(index<0||!components.length)throw new Error('Choose a combined level to separate.'); + const before=order.slice(0,index).filter(key=>!components.includes(key)); + const after=order.slice(index+1).filter(key=>!components.includes(key)); + const next=[...before,...components,...after]; + if(next.length>8)throw new Error('Remove a level first; separating these fields would exceed eight levels.'); + return next; +} diff --git a/workspace-app/src/jointGrouping.test.js b/workspace-app/src/jointGrouping.test.js index c3ce6cb..23fc151 100644 --- a/workspace-app/src/jointGrouping.test.js +++ b/workspace-app/src/jointGrouping.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {jointId,jointComponents,jointDefinition,combineLevels} from './jointGrouping.js'; +import {jointId,jointComponents,jointDefinition,combineLevels,uncombineLevel} from './jointGrouping.js'; test('composite IDs survive legacy comma-separated recipes and round-trip exact field IDs',()=>{ const fields=['parameters/history1','metadata/cell/properties/a+b,c%2F']; @@ -24,3 +24,11 @@ test('saved composites show recorded labels and refuse unknown component identit assert.equal(jointDefinition('joint/a+b',fields).label,'History 1 + Target'); assert.equal(jointDefinition('joint/a+c',fields),null); }); + +test('combined levels separate in place with complete unique fields and enforce the level limit',()=>{ + const original=['date','cell','h1','h2','target','block']; + const combined=combineLevels(original,['h1','h2','target']); + assert.deepEqual(uncombineLevel(combined,jointId(['h1','h2','target'])),original); + assert.deepEqual(uncombineLevel(['h1','date',jointId(['h1','h2']),'h2','block'],jointId(['h1','h2'])),['date','h1','h2','block']); + assert.throws(()=>uncombineLevel(['a','b','c','d','e','f','g',jointId(['h1','h2'])],jointId(['h1','h2'])),/eight levels/); +}); From 4e8cca23c5b4e134a5f612f8f3f27597179fe4f8 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:47:44 -0700 Subject: [PATCH 7/8] Keep tree designer navigation steady and contain column scrolling --- workspace-app/src/columnTreeNavigation.js | 7 +++++ .../src/columnTreeNavigation.test.js | 11 ++++++- workspace-app/src/components/ColumnTree.css | 6 +++- workspace-app/src/components/ColumnTree.jsx | 29 +++++++++++++++---- workspace-app/src/components/Inspector.jsx | 2 +- .../src/components/MetadataExplorer.jsx | 4 ++- workspace-app/src/components/TreeBuilder.jsx | 6 ++-- workspace-app/src/pagedTreeRequest.js | 6 ++++ workspace-app/src/pagedTreeRequest.test.js | 14 ++++++++- 9 files changed, 72 insertions(+), 13 deletions(-) diff --git a/workspace-app/src/columnTreeNavigation.js b/workspace-app/src/columnTreeNavigation.js index e9e1c81..8cbe6d6 100644 --- a/workspace-app/src/columnTreeNavigation.js +++ b/workspace-app/src/columnTreeNavigation.js @@ -20,3 +20,10 @@ export function columnBranchNavigation(page,item,expandedKey){ const opening=expandedKey!==item.key; return {opening,path:opening?item.path:page.path,offset:opening?0:page.offset}; } + +// Horizontal gestures belong to the whole column strip, including at its edges. +// Leave ordinary vertical gestures to the column under the pointer. +export function columnWheelDelta({deltaX=0,deltaY=0,shiftKey=false,deltaMode=0},width){ + const horizontal=shiftKey?(deltaX||deltaY):Math.abs(deltaX)>Math.abs(deltaY)?deltaX:0; + return horizontal*(deltaMode===1?16:deltaMode===2?width:1); +} diff --git a/workspace-app/src/columnTreeNavigation.test.js b/workspace-app/src/columnTreeNavigation.test.js index 925b65e..a792072 100644 --- a/workspace-app/src/columnTreeNavigation.test.js +++ b/workspace-app/src/columnTreeNavigation.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation} from './columnTreeNavigation.js'; +import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation,columnWheelDelta} from './columnTreeNavigation.js'; test('selected epoch reconstructs ancestor pages beyond the first sixty branches',()=>{ const page={revision:'exact',path:['date-key','cell-key','block-key'],ancestors:[{parent_offset:120},{parent_offset:60},{parent_offset:180}]}; @@ -31,3 +31,12 @@ test('closing a column branch returns to its parent page without selecting a des assert.deepEqual(columnBranchNavigation(page,cell,'cell3'),{opening:false,path:['date'],offset:60}); assert.deepEqual(columnBranchNavigation(page,cell,'cell5'),{opening:true,path:['date','cell3'],offset:0}); }); + + +test('horizontal and Shift-wheel gestures cross columns while vertical gestures stay local',()=>{ + assert.equal(columnWheelDelta({deltaX:90,deltaY:3},600),90); + assert.equal(columnWheelDelta({deltaX:-90,deltaY:3},600),-90); + assert.equal(columnWheelDelta({deltaX:3,deltaY:90},600),0); + assert.equal(columnWheelDelta({deltaY:4,shiftKey:true,deltaMode:1},600),64); + assert.equal(columnWheelDelta({deltaX:1,deltaMode:2},600),600); +}); diff --git a/workspace-app/src/components/ColumnTree.css b/workspace-app/src/components/ColumnTree.css index ebc4540..daca18b 100644 --- a/workspace-app/src/components/ColumnTree.css +++ b/workspace-app/src/components/ColumnTree.css @@ -1,4 +1,8 @@ -.column-tree{min-width:0}.column-tree .tp-columns{scroll-behavior:auto}.column-tree .tp-column{width:230px;min-width:230px}.column-tree .tp-combined-column{width:300px;min-width:300px}.column-tree .tp-terminal{width:270px;min-width:270px}.column-tree .tp-pagination{flex-shrink:0;border-top:1px solid var(--border);padding:8px;background:var(--surface)}.column-tree .tp-branch-title strong,.column-tree .tp-level-heading strong{color:var(--ink)}.column-tree .tp-branch.selected{border-left:3px solid var(--accent)}.column-tree .tp-branch:disabled{cursor:wait}.column-tree .tp-column-content{overscroll-behavior:contain}.column-tree .tp-total{padding:13px 18px}.column-tree .tp-path{padding:8px 12px}.column-tree .tp-prompt{min-width:150px}.column-tree .tp-level-heading small{color:var(--ink-secondary)} +.column-tree{min-width:0;position:relative}.column-tree .tp-columns{scroll-behavior:auto;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain}.column-tree .tp-column{width:230px;min-width:230px}.column-tree .tp-combined-column{width:300px;min-width:300px}.column-tree .tp-terminal{width:270px;min-width:270px}.column-tree .tp-pagination{flex-shrink:0;border-top:1px solid var(--border);padding:8px;background:var(--surface)}.column-tree .tp-branch-title strong,.column-tree .tp-level-heading strong{color:var(--ink)}.column-tree .tp-branch.selected{border-left:3px solid var(--accent)}.column-tree .tp-branch:disabled{cursor:wait}.column-tree .tp-column-content{overscroll-behavior-y:contain;overscroll-behavior-x:auto;overflow-x:hidden}.column-tree .tp-total{padding:13px 18px}.column-tree .tp-path{padding:8px 12px}.column-tree .tp-prompt{min-width:150px}.column-tree .tp-level-heading small{color:var(--ink-secondary)} .column-tree .tp-epoch.selected{background:#eee7f3;border-left:3px solid var(--accent);color:var(--ink);scroll-margin-block:8px}.column-tree .tp-epoch.selected strong{color:var(--ink);font-weight:650}.column-tree .tp-branch.selected{scroll-margin-block:8px} .column-tree .tp-epoch{padding:5px 7px;min-height:30px;display:flex;flex-direction:row;align-items:center;gap:6px}.column-tree .tp-epoch>strong{display:flex;align-items:center;gap:6px;min-width:0;font-size:11px;white-space:nowrap}.column-tree .column-epoch-protocol{font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.column-tree .tp-epoch .annotation-row-pills{max-width:110px} + +.column-tree .tp-loading-notice{position:absolute;right:12px;top:10px;pointer-events:none;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface);font-size:11px;color:var(--ink-secondary)} + +.column-tree[aria-busy=true] .tp-branch:disabled,.column-tree[aria-busy=true] .tp-epoch:disabled,.column-tree[aria-busy=true] .tp-path button:disabled{opacity:1} diff --git a/workspace-app/src/components/ColumnTree.jsx b/workspace-app/src/components/ColumnTree.jsx index 8c20507..df4df66 100644 --- a/workspace-app/src/components/ColumnTree.jsx +++ b/workspace-app/src/components/ColumnTree.jsx @@ -1,11 +1,12 @@ import EpochInclusionToggle from './EpochInclusionToggle.jsx'; +import {useDelayedLoading} from './NavigationLoading.jsx'; import {revealWithin} from '../epochListScroll.js'; import {useCallback,useEffect,useRef,useState} from 'react'; import {ArrowLeft,ArrowRight,ChevronRight,FolderOpen,Home,Activity,GitBranch} from 'lucide-react'; import {api,number,duration} from '../api.js'; import {treePageRequest,treeNavigationStart,treeNavigationSnapshot} from '../pagedTreeRequest.js'; import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; -import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation} from '../columnTreeNavigation.js'; +import {columnAncestorPages,canReuseColumn,columnSelectionNeedsAnchor,columnBranchNavigation,columnWheelDelta} from '../columnTreeNavigation.js'; import {datedCellLabel} from '../recordingIdentity.js'; import './TreePreview.css'; import './ColumnTree.css'; @@ -18,6 +19,7 @@ export default function ColumnTree(props){ const callbacks=useRef(props);callbacks.current=props; const saved=useRef(initialNavigation),initialScope=useRef(scopeKey),restored=useRef(false); const [state,setState]=useState({columns:[],loading:true,error:null}); + const showLoading=useDelayedLoading(state.loading&&!state.error); const current=useRef([]),controller=useRef(null),serial=useRef(0),pending=useRef(true); const latestSelected=useRef(selected),previousSelected=useRef(selected);latestSelected.current=selected; const strip=useRef(null),panes=useRef(new Map()),scrollRestore=useRef(null); @@ -26,11 +28,25 @@ export default function ColumnTree(props){ const last=current.current.at(-1);if(!last||pending.current)return; callbacks.current.onNavigationChange?.({...treeNavigationSnapshot(last,panes.current.get(last.depth)?.scrollTop||0),columnPositions:positions(),scrollLeft:strip.current?.scrollLeft||0}); } + useEffect(()=>{ + const element=strip.current; + if(!element)return; + const wheel=event=>{ + const delta=columnWheelDelta(event,element.clientWidth); + if(!delta)return; + // Consume the gesture even at the boundary; it must not navigate browser history. + event.preventDefault();element.scrollLeft+=delta; + }; + element.addEventListener('wheel',wheel,{passive:false}); + return()=>element.removeEventListener('wheel',wheel); + },[]); const load=useCallback(async({path=[],offset=0,reset=false,revisionOverride=null,columnPositions=[],scrollTop=0,scrollLeft=null,anchor=null}={})=>{ controller.current?.abort();const request=new AbortController();controller.current=request;const token=++serial.current;pending.current=true; const prior=current.current,priorPositions=prior.map(page=>({offset:page.offset,scrollTop:panes.current.get(page.depth)?.scrollTop||0})); if(reset)current.current=[]; - setState(old=>({columns:reset?[]:old.columns,loading:true,error:null}));callbacks.current.onStatus?.({loading:true,error:null}); + // Retain the last columns visually while the new scope loads; blocked rows + // cannot act on the previous revision. Commit the replacement atomically. + setState(old=>({columns:old.columns,loading:true,error:null}));callbacks.current.onStatus?.({loading:true,error:null}); const scope=JSON.parse(scopeKey); const fetchPage=(pagePath,pageOffset,pageRevision,pageAnchor=null)=>api('/tree-pages',{method:'POST',signal:request.signal,body:treePageRequest(scope,{path:pagePath,offset:pageOffset,anchor:pageAnchor,reset:reset&&!pageRevision,currentRevision:pageRevision})}); try{ @@ -86,7 +102,7 @@ export default function ColumnTree(props){ },[selected,state.columns,state.loading,state.error]); const last=state.columns.at(-1),root=state.columns[0],path=last?.path||[]; const ancestors=last?.ancestors||path.map((key,depth)=>state.columns[depth]?.branches?.find(branch=>branch.key===key)).filter(Boolean); - return
+ return
{props.design&&
{root?`${number(root.total_epochs)} epochs`:'Loading tree…'}{root?`${number(root.cells)} cells · ${duration(root.duration_seconds)}`:''}{last?.split_order.length??splits.split(',').filter(Boolean).length} split levels
} {props.design&&} {state.error&&
{state.error}
} @@ -94,7 +110,7 @@ export default function ColumnTree(props){ {state.columns.map((page,depth)=>{ const terminal=page.kind==='epochs',entries=terminal?page.epochs.map(item=>props.inclusionForEpoch?props.inclusionForEpoch(item):item):page.branches,field=page.levels?.[depth],combined=entries.some(item=>item.components?.length),blocked=state.loading||!!state.error; return
-
{terminal?:depth+1}
{terminal?'Inspect epochs':readableField(field?.label,field?.field)}{number(page.total)} {terminal?'epochs':'groups'} · {number(page.selection?.count??page.total_epochs)} epochs in scope
+
{terminal?:depth+1}
{terminal?'Epochs':readableField(field?.label,field?.field)}{number(page.total)} {terminal?'epochs':'groups'} · {number(page.selection?.count??page.total_epochs)} epochs in scope
{if(element)panes.current.set(depth,element);else panes.current.delete(depth);}} onScroll={remember}> {entries.map((item,index)=>terminal?
{props.onToggleInclusion&&}
:{page.total?page.offset+1:0}–{page.offset+entries.length} / {number(page.total)}
; })} - {state.loading?
Loading columns…
:last?.kind!=='epochs'&&!state.error&&
Choose a group

Its next split opens alongside this column.

} + {state.loading&&!state.columns.length?
{showLoading?'Loading tree…':''}
:last?.kind!=='epochs'&&!state.error&&
Choose a group

Its next split opens alongside this column.

} -
Tree splits preserve the full selection.Scroll across to see earlier splits. Inspect an epoch, then return to this overview.
+ {showLoading&&!!state.columns.length&&
Updating tree…
} +
Tree splits preserve the full selection.Scroll left or right between levels. Select an epoch here, then use Back to epochs to inspect it.
; } diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index e208721..1b5b16d 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -223,7 +223,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi {error&&
{error}
} } layout={{layoutRef,sizes:paneSizes,treeOpen,metadataOpen,onResize:changePane,onResizeCommit:savePane}} designMode={designMode} - builder={{protocolId:id,queryString:protocolSearch,revision:revision,value:splits.split(',').filter(Boolean),onChange:changeSplits,preview:tree.data,loading:tree.loading,error:tree.error}} columnTree={{collapseRequest:collapseRequest,externalCollapseControl:true,actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},presentation:"columns",protocolId:id,filters:filters,splits:splits,revision:revision,design:true,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onMetadata:receiveTree,onStatus:setTreeStatus,onSelectEpoch:(uuid,info)=>{focusTreeEpoch(uuid,info);setTreeMode(false);setDesignMode(false);}}} treePane={{treeMode:treeMode,onTreeMode:value=>{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));},onDesign:()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);},designDisabled:busy,collapseRequest:collapseRequest,onCollapse:()=>setCollapseRequest(value=>value+1),treeProps:{actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},protocolId:id,filters,splits,revision,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onSelectEpoch:focusTreeEpoch,onMetadata:receiveTree,onStatus:setTreeStatus},listRef:epochListRef,listKey:`${id}:${protocolSearch}`,listProps:{cells:protocol.cells||[],source:{kind:'protocol',protocolId:id,query:protocolSearch},revision,focused:cellTagRequest?null:focused,onFocus:focusTreeEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets:selectOverviewTargets,disabled:busy,onToggleInclusion:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}}} + builder={{protocolId:id,queryString:protocolSearch,revision:revision,value:splits.split(',').filter(Boolean),onChange:changeSplits,preview:tree.data,loading:tree.loading,error:tree.error}} columnTree={{collapseRequest:collapseRequest,externalCollapseControl:true,actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},presentation:"columns",protocolId:id,filters:filters,splits:splits,revision:revision,design:true,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onMetadata:receiveTree,onStatus:setTreeStatus,onSelectEpoch:focusTreeEpoch}} treePane={{treeMode:treeMode,onTreeMode:value=>{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));},onDesign:()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);},designDisabled:busy,collapseRequest:collapseRequest,onCollapse:()=>setCollapseRequest(value=>value+1),treeProps:{actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},protocolId:id,filters,splits,revision,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onSelectEpoch:focusTreeEpoch,onMetadata:receiveTree,onStatus:setTreeStatus},listRef:epochListRef,listKey:`${id}:${protocolSearch}`,listProps:{cells:protocol.cells||[],source:{kind:'protocol',protocolId:id,query:protocolSearch},revision,focused:cellTagRequest?null:focused,onFocus:focusTreeEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets:selectOverviewTargets,disabled:busy,onToggleInclusion:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}}} resource={{scope:id,...epoch,loading:!epoch.error&&(!!pendingNavigation||(!!focused&&(epoch.loading||!focusedEpoch))),retry:epoch.reload}} epoch={focusedEpoch} targets={targets} navigation={{position:focusedPageIndex<0?-1:offset+focusedPageIndex,total:rows.data?.total||0,loading:!!pendingNavigation||rows.loading,disabled:busy,onMove:moveEpoch}} traceRevision={revision} inclusion={{disabled:busy,scope:'pinned dataset',onToggle:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}} detailDisabled={busy} onQC={onQC} tags={tagEntry} diff --git a/workspace-app/src/components/MetadataExplorer.jsx b/workspace-app/src/components/MetadataExplorer.jsx index 95ac24a..e62a8ff 100644 --- a/workspace-app/src/components/MetadataExplorer.jsx +++ b/workspace-app/src/components/MetadataExplorer.jsx @@ -1,4 +1,5 @@ import EpochViewer from './EpochViewer.jsx'; +import {treePreviewScope} from '../pagedTreeRequest.js'; import {predicateWithTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; import {searchInclusionPredicate,searchEpochInclusion,toggleSearchInclusion} from '../searchInclusion.js'; import SearchPresets from './SearchPresets.jsx'; @@ -183,6 +184,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, const resultLoading=hasViewFilter?filteredPreview.loading||!currentFilteredPreview&&!filteredPreview.error:(resultsFromDraft?draftPreview.loading:tree.loading); const resultError=hasViewFilter&&filteredPreview.key===filteredKey?filteredPreview.error:(resultsFromDraft?draftPreview.error:tree.error); const displayTree=hasViewFilter?{data:currentFilteredPreview,loading:resultLoading,error:resultError}:tree; + useEffect(()=>{const node=layoutRef.current;if(!node)return;const observer=new ResizeObserver(entries=>setLayoutWidth(entries[0].contentRect.width));observer.observe(node);return()=>observer.disconnect();},[step,focused,initialCandidateLoading,!!displayTree.data,displayTree.loading,!!displayTree.error]); useEffect(()=>{ if(!hasViewFilter||!resultPredicate||step==='filter'||!baseResultPreview)return; @@ -278,7 +280,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, toolbar={{portalTarget:epochToolbarTarget,designMode:true,onBrowse:showEpochResults,onExport:openResultsExport,exportDisabled,actions:resultActions}} layout={{layoutRef,className:'mx-layout',sizes:pane,treeOpen:true,metadataOpen:false,onResize:(name,value)=>{if(name==='tree')setGroupingWidth(value);},onResizeCommit:(name,value)=>{if(name==='tree')try{localStorage.setItem('workspace.explorer.groupingWidth',String(value));}catch{}}}} builder={{catalogData:displayTree.data.catalog,value:splits.split(',').filter(Boolean),onChange:changeSplits,preview:pagedInfo&&pagedInfo.split_order?.join(',')===splits?{...displayTree.data.tree,...pagedInfo,count:displayTree.data.matched_count}:displayTree.data.tree,loading:displayTree.loading||pagedStatus.loading,error:displayTree.error||pagedStatus.error}} - columnTree={{inclusionForEpoch:epoch=>searchEpochInclusion(epoch,excludedEpochs),onToggleInclusion:toggleInclusion,actionsDisabled:busy,predicate:resultPredicate,splits,revision:`${viewRevision}:${generation}`,initialNavigation:treeNavigation,onNavigationChange:setTreeNavigation,expectedRevision:displayTree.data.tree_revision,onRefreshPreview:()=>setGeneration(value=>value+1),onMetadata:setPagedInfo,onStatus:setPagedStatus,onSelectEpoch:uuid=>{setMatchingNavigation({revision:displayTree.data.tree_revision,focused:uuid});setFocused(null);setResultsFromDraft(false);setStep('results');}}}/>} + columnTree={{inclusionForEpoch:epoch=>searchEpochInclusion(epoch,excludedEpochs),onToggleInclusion:toggleInclusion,actionsDisabled:busy,predicate:resultPredicate,...treePreviewScope(displayTree.data),revision:`${viewRevision}:${generation}`,initialNavigation:treeNavigation,onNavigationChange:setTreeNavigation,onRefreshPreview:()=>setGeneration(value=>value+1),onMetadata:setPagedInfo,onStatus:setPagedStatus,selected:matchingNavigation?.focused,onSelectEpoch:uuid=>setMatchingNavigation({revision:displayTree.data.tree_revision,focused:uuid})}}/>} } ; diff --git a/workspace-app/src/components/TreeBuilder.jsx b/workspace-app/src/components/TreeBuilder.jsx index 9d9ac00..1a7dd5d 100644 --- a/workspace-app/src/components/TreeBuilder.jsx +++ b/workspace-app/src/components/TreeBuilder.jsx @@ -8,6 +8,7 @@ import { startPointerDrag } from '../pointerDrag.js'; import {COMMON_TREE_FIELDS,treeFieldLabel,treeFieldHint,treeFieldExamples,treeFieldMatches,groupingFieldRank} from '../treeFieldPresentation.js'; import {jointDefinition,shortFieldLabel,uncombineLevel} from '../jointGrouping.js'; import JointGroupingEditor from './JointGroupingEditor.jsx'; +import {useDelayedLoading} from './NavigationLoading.jsx'; const categories = ['Common', 'Parameters', 'Combinations', 'Conditions', 'Suggested', 'All']; const categoryLabel = category => category === 'Suggested' ? 'Recommended' : category === 'Parameters' ? 'Protocol settings' : category === 'All' ? 'All metadata' : category; @@ -145,6 +146,7 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query } const isCurrent = !loading && sameOrder((preview?.levels || []).map(level=>level.field),order); const pending = loading || orderKey!==valueKey; + const showPending=useDelayedLoading(pending&&!error); async function copyMatlab(){ if(!isCurrent||pending||error||!preview?.matlab_command)return; try{await navigator.clipboard.writeText(preview.matlab_command);setCopyMessage({text:'Copied EpicTreeGUI command',error:false});} @@ -210,8 +212,8 @@ export default function TreeBuilder({protocolId, catalogPath, catalogData, query

Source values only. Missing values remain in the tree.

}
- {pending?:error?:} - {pending?'Updating tree…':error?'Tree could not be updated. Your data is unchanged.':`${number(preview?.count ?? catalog.data?.total)} matching epochs · tree preview`} + {showPending?:error?:} + {showPending?'Updating tree…':error?'Tree could not be updated. Your data is unchanged.':`${number(preview?.count ?? catalog.data?.total)} matching epochs · tree preview`}
EpicTreeGUI code

Export this layout to EpicTreeGUI, then run this line from the extracted bundle with EpicTreeGUI on your MATLAB path.

diff --git a/workspace-app/src/pagedTreeRequest.js b/workspace-app/src/pagedTreeRequest.js index 372d194..dbff34d 100644 --- a/workspace-app/src/pagedTreeRequest.js +++ b/workspace-app/src/pagedTreeRequest.js @@ -12,3 +12,9 @@ export function treeNavigationStart(saved,splits){ if(!saved||!Array.isArray(saved.path)||saved.path.length>8||!saved.path.every(key=>typeof key==='string'&&/^[0-9a-f]{64}$/.test(key))||!Number.isSafeInteger(saved.offset)||saved.offset<0||typeof saved.revision!=='string'||!/^[0-9a-f]{64}$/.test(saved.revision)||saved.split_order?.join(',')!==splits)return null; return {path:saved.path,offset:saved.offset,revisionOverride:saved.revision,scrollTop:Number.isFinite(saved.scrollTop)?Math.max(0,saved.scrollTop):0}; } + +// Compact previews carry split_order, without expanded tree levels. Keep their +// exact revision and layout together while a replacement preview is pending. +export function treePreviewScope(preview){ + return {splits:preview.tree.split_order.join(','),expectedRevision:preview.tree_revision}; +} diff --git a/workspace-app/src/pagedTreeRequest.test.js b/workspace-app/src/pagedTreeRequest.test.js index 90f5e3f..f7fed2e 100644 --- a/workspace-app/src/pagedTreeRequest.test.js +++ b/workspace-app/src/pagedTreeRequest.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {treePageRequest,treeNavigationSnapshot,treeNavigationStart} from './pagedTreeRequest.js'; +import {treePageRequest,treeNavigationSnapshot,treeNavigationStart,treePreviewScope} from './pagedTreeRequest.js'; test('bounded tree pages preserve filters and opaque paths without posting full memberships',()=>{ const filters={cell_type:'OFF parasol'}; @@ -38,3 +38,15 @@ test('tree return preserves scroll locally without sending it into scientific pa assert.equal(treeNavigationStart({...page,scrollTop:-1},'date,cell').scrollTop,0); assert.equal(treeNavigationStart({...page,scrollTop:Infinity},'date,cell').scrollTop,0); }); + + +test('compact preview revisions stay paired with their own layouts during rapid designer changes',()=>{ + const predicate={all:[]},old={tree_revision:'a'.repeat(64),tree:{count:173,split_order:['date','cell']}}; + const pending={predicate,splits:'date,cell,group,block',...treePreviewScope(old)}; + const body=treePageRequest(pending,{reset:true}); + assert.equal(body.splits,'date,cell'); + assert.equal(body.revision,old.tree_revision); + const flat={tree_revision:'b'.repeat(64),tree:{count:173,split_order:[]}}; + const refreshed=treePageRequest({...pending,...treePreviewScope(flat)},{reset:true}); + assert.equal(refreshed.splits,'');assert.equal(refreshed.revision,flat.tree_revision); +}); From 9588df76a139e01228f92c7c938bb790f7cf9c2b Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:52:53 -0700 Subject: [PATCH 8/8] Place designer return control in the shared left pane --- workspace-app/src/components/EpochBrowserChrome.jsx | 2 +- workspace-app/src/components/EpochViewer.css | 2 ++ workspace-app/src/components/EpochViewer.jsx | 4 +++- workspace-app/src/epochViewerRendering.test.js | 12 ++++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 workspace-app/src/components/EpochViewer.css diff --git a/workspace-app/src/components/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx index d8b7c8c..ef2cc89 100644 --- a/workspace-app/src/components/EpochBrowserChrome.jsx +++ b/workspace-app/src/components/EpochBrowserChrome.jsx @@ -4,7 +4,7 @@ import {number} from '../api.js'; import InspectorActions from './InspectorActions.jsx'; export function EpochBrowserToolbar({designMode=false,designDisabled=false,onBrowse,onDesign,onTags,metadataOpen,onToggleMetadata,onExport,exportDisabled=false,actions=[],children,treeControlsInPane=false,filterControl=null,portalTarget=null}){ - const toolbar=
{(!treeControlsInPane||designMode)&&}{!designMode&&<>{filterControl}}{children}{onExport&&}
; + const toolbar=
{!treeControlsInPane&&}{!designMode&&<>{filterControl}}{children}{onExport&&}
; return portalTarget?createPortal(toolbar,portalTarget):toolbar; } export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ diff --git a/workspace-app/src/components/EpochViewer.css b/workspace-app/src/components/EpochViewer.css new file mode 100644 index 0000000..7533026 --- /dev/null +++ b/workspace-app/src/components/EpochViewer.css @@ -0,0 +1,2 @@ +.tree-design-controls{display:flex;align-items:center;flex-shrink:0;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface)} +.tree-design-controls button{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap} diff --git a/workspace-app/src/components/EpochViewer.jsx b/workspace-app/src/components/EpochViewer.jsx index ee78eca..0e9f50d 100644 --- a/workspace-app/src/components/EpochViewer.jsx +++ b/workspace-app/src/components/EpochViewer.jsx @@ -1,4 +1,6 @@ import ScientificContext from './ScientificContext.jsx'; +import {ArrowLeft} from 'lucide-react'; +import './EpochViewer.css'; import {EpochBrowserToolbar,EpochNavigation} from './EpochBrowserChrome.jsx'; import EpochBrowserLayout from './EpochBrowserLayout.jsx'; import EpochTreePane from './EpochTreePane.jsx'; @@ -27,7 +29,7 @@ export default function EpochViewer({className='epoch-inspector-mode',ariaLabel= {toolbar&&:toolbar.filterControl} treeControlsInPane>{toolbarChildren}{!designMode&&onViewFilters&&tagFilterLabel(viewFilters)&&{tagFilterLabel(viewFilters)}}} {before} :{!layout.metadataOpen&&{tags}}} + tree={designMode?<>:{!layout.metadataOpen&&{tags}}} detail={designMode?:detail} metadata={metadata&&}/>}/> ; diff --git a/workspace-app/src/epochViewerRendering.test.js b/workspace-app/src/epochViewerRendering.test.js index 6e0f504..c12a31e 100644 --- a/workspace-app/src/epochViewerRendering.test.js +++ b/workspace-app/src/epochViewerRendering.test.js @@ -50,3 +50,15 @@ test('common recorded scientific context preserves source facts and marks config assert.match(text,/0 \/ 675/); assert.match(html,/fixture\.h5/); }); + + +test('shared designer puts its only Back to epochs control in the left tree pane',async()=>{ + const {default:EpochViewer}=await server.ssrLoadModule('/src/components/EpochViewer.jsx'); + const html=render(EpochViewer,{designMode:true,toolbar:{designMode:true,onBrowse:()=>{}}, + layout:{sizes:{columns:'320px 7px minmax(0,1fr)',tree:320,treeMax:560},treeOpen:true,metadataOpen:false,onResize:()=>{}}, + builder:{catalogData:{fields:[]},value:[],onChange:()=>{},preview:{count:0,levels:[]}},columnTree:{predicate:{all:[]},splits:''}}); + assert.equal((html.match(/Back to epochs/g)||[]).length,1); + const leftPane=html.match(/