From 96bce7012eae9b175d4d9ff6fed894e30a5f8625 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:38:34 -0700 Subject: [PATCH 1/9] Simplify epoch browser controls and fix nested split labels --- .../src/components/EpochBrowserChrome.jsx | 2 +- workspace-app/src/components/HierarchyTree.css | 14 ++++++++++++++ workspace-app/src/components/HierarchyTree.jsx | 2 +- .../src/components/InspectionCellTree.jsx | 7 +++---- workspace-app/src/components/Inspector.css | 7 +++++++ workspace-app/src/components/Inspector.jsx | 5 ++--- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/workspace-app/src/components/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx index a67ca50..3dc7498 100644 --- a/workspace-app/src/components/EpochBrowserChrome.jsx +++ b/workspace-app/src/components/EpochBrowserChrome.jsx @@ -3,7 +3,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}){ - return
{!designMode&&<>}{children}{onExport&&}
; + return
{!designMode&&<>}{children}{onExport&&}
; } export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ return targets.length>0&&
{number(targets.length)} epochs selected
; diff --git a/workspace-app/src/components/HierarchyTree.css b/workspace-app/src/components/HierarchyTree.css index e20ba1d..40e9995 100644 --- a/workspace-app/src/components/HierarchyTree.css +++ b/workspace-app/src/components/HierarchyTree.css @@ -5,3 +5,17 @@ .ht-children{margin-left:14px;padding-left:9px} .ht-children>li:before{left:-9px;width:9px} .ht-value{min-width:160px;overflow-wrap:break-word} + +/* Nested split values wrap in their own rows instead of crowding epoch counts. */ +.ht-root{min-width:0} +.ht-value{min-width:0;overflow-wrap:anywhere;line-height:1.5} +.ht-branch>button{align-items:flex-start;padding:9px 7px;gap:8px} +.ht-branch>button>svg,.ht-folder{margin-top:2px} +.ht-count{margin-top:2px;white-space:nowrap} +.ht-combination{display:grid;gap:9px;width:100%;min-width:0} +.ht-combination>span{display:grid;gap:2px;min-width:0;line-height:1.5;overflow-wrap:anywhere} +.ht-combination small{display:block;font-size:11px;font-weight:550;line-height:1.4} +.ht-children{margin-left:12px;padding-left:8px} +.ht-children>li:before{left:-8px;width:8px;top:23px} +.ht-splits{gap:6px 10px;padding:10px 14px;line-height:1.6} +.ht-splits>span{min-width:0;overflow-wrap:anywhere} diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 3bde21f..95fe8f2 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -100,7 +100,7 @@ export default function HierarchyTree(props){ {entries.map((item,index)=>{ if(page.kind==='epochs')return
  • ; 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.'}
    )}
  • ; + return
  • {opened&&(children?renderPage(children):
    {loading?'Loading branch…':'Branch is not loaded.'}
    )}
  • ; })} {!entries.length&&
  • No matching epochs.
  • } {(page.offset>0||page.has_more)&&
  • {page.offset+1}–{page.offset+entries.length} / {number(page.total)}
  • } diff --git a/workspace-app/src/components/InspectionCellTree.jsx b/workspace-app/src/components/InspectionCellTree.jsx index 98f22d8..5cac486 100644 --- a/workspace-app/src/components/InspectionCellTree.jsx +++ b/workspace-app/src/components/InspectionCellTree.jsx @@ -10,11 +10,10 @@ import {Status} from './Common.jsx'; import './InspectionCellTree.css'; import {useEpochBrowserPage} from '../useEpochBrowserPage.js'; -function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,onSelectPage,disabled,onToggleInclusion}){ +function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disabled,onToggleInclusion}){ const [offset,setOffset]=useState(0); const page=useEpochBrowserPage(source,{cellUuid:cell.cell_uuid,offset},revision); return -
    {(page.data?.epochs||[]).map((epoch,index)=>
    {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="tree" protocolId={id} filters={filters} splits={splits} revision={revision} selected={focused} initialNavigation={designNavigation} onNavigationChange={setDesignNavigation} onSelectEpoch={focusTreeEpoch} onMetadata={receiveTree} onStatus={setTreeStatus}/> :<>
    {focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);}} targets={targets} setTargets={selectOverviewTargets} disabled={busy} onToggleInclusion={(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}/>
    @@ -319,7 +318,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi } } {treeOpen&&changePane('tree',value)} onCommit={value=>savePane('tree',value)}/>} - {designMode?{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);}}/>:
    {cellTagRequest||targets.length>0?:focusedEpoch?<> + {designMode?{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?<>

    {datedCellLabel(focusedEpoch)}

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

    From ee8b883aee0c16811a19b93f8e9558bd0309ccd2 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:41:43 -0700 Subject: [PATCH 2/9] Add epoch inclusion and tagging actions to split trees --- workspace-app/src/components/HierarchyTree.css | 10 ++++++++++ workspace-app/src/components/HierarchyTree.jsx | 4 ++-- workspace-app/src/components/Inspector.jsx | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/workspace-app/src/components/HierarchyTree.css b/workspace-app/src/components/HierarchyTree.css index 40e9995..2a5a33c 100644 --- a/workspace-app/src/components/HierarchyTree.css +++ b/workspace-app/src/components/HierarchyTree.css @@ -19,3 +19,13 @@ .ht-children>li:before{left:-8px;width:8px;top:23px} .ht-splits{gap:6px 10px;padding:10px 14px;line-height:1.6} .ht-splits>span{min-width:0;overflow-wrap:anywhere} + +.ht-leaf{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px} +.ht-leaf>button:first-child{min-width:0;flex-wrap:wrap} +.ht-leaf .annotation-row-pills{max-width:100%;flex-wrap:wrap} +.ht-leaf>button.ht-tag-action,.ht-leaf>button.ht-inclusion-action{width:auto;min-height:28px;padding:5px 6px;gap:4px;justify-content:center;font-size:10px;white-space:nowrap} +.ht-leaf>button.ht-tag-action{color:var(--accent);border:1px solid var(--border)} +.ht-leaf>button.ht-inclusion-action,.ht-inclusion-state{color:#467554;background:#edf5ef;border-radius:5px} +.ht-leaf>button.ht-inclusion-action.excluded,.ht-inclusion-state.excluded{color:#9b6255;background:#faf0ec} +.ht-inclusion-state{font-size:10px;padding:5px} +.ht-inclusion-action[aria-pressed=false]{text-decoration:none} diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 95fe8f2..359f9ab 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -1,6 +1,6 @@ import {revealWithin} from '../epochListScroll.js'; import {useCallback,useEffect,useRef,useState} from 'react'; -import {Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; +import {Check,X,Tag,Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; import {api,number,duration} from '../api.js'; import {treePageRequest} from '../pagedTreeRequest.js'; import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; @@ -98,7 +98,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
    • ; + if(page.kind==='epochs')return
    • {props.onTagEpoch&&}{props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.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/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index fb6e256..40c3a51 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -311,14 +311,14 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi {designMode?:treeMode?<> - {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="tree" protocolId={id} filters={filters} splits={splits} revision={revision} selected={focused} initialNavigation={designNavigation} onNavigationChange={setDesignNavigation} onSelectEpoch={focusTreeEpoch} onMetadata={receiveTree} onStatus={setTreeStatus}/> + curate({included},'focused',item.epoch_uuid)} onTagEpoch={(uuid,item)=>{focusTreeEpoch(uuid,item);setTargets([]);setTagFocus(value=>value+1);}} 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="tree" protocolId={id} filters={filters} splits={splits} revision={revision} selected={focused} initialNavigation={designNavigation} onNavigationChange={setDesignNavigation} onSelectEpoch={focusTreeEpoch} onMetadata={receiveTree} onStatus={setTreeStatus}/> :<>
      {focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);}} targets={targets} setTargets={selectOverviewTargets} disabled={busy} onToggleInclusion={(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}/>
      {!metadataOpen&&{tagEntry}} } } {treeOpen&&changePane('tree',value)} onCommit={value=>savePane('tree',value)}/>} - {designMode?{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?<> + {designMode?curate({included},'focused',item.epoch_uuid)} onTagEpoch={(uuid,item)=>{focusTreeEpoch(uuid,item);setTargets([]);setTagFocus(value=>value+1);}} 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?<>

      {datedCellLabel(focusedEpoch)}

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

      From 8050bcd93d75892e30ee85682d5ecad4194947ae Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:43:08 -0700 Subject: [PATCH 3/9] Share compact inclusion controls between epoch and tree views --- workspace-app/src/components/EpochInclusionToggle.css | 4 ++++ workspace-app/src/components/EpochInclusionToggle.jsx | 7 +++++++ workspace-app/src/components/HierarchyTree.css | 5 +---- workspace-app/src/components/HierarchyTree.jsx | 5 +++-- workspace-app/src/components/InspectionCellTree.css | 3 --- workspace-app/src/components/InspectionCellTree.jsx | 4 ++-- 6 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 workspace-app/src/components/EpochInclusionToggle.css create mode 100644 workspace-app/src/components/EpochInclusionToggle.jsx diff --git a/workspace-app/src/components/EpochInclusionToggle.css b/workspace-app/src/components/EpochInclusionToggle.css new file mode 100644 index 0000000..3301e40 --- /dev/null +++ b/workspace-app/src/components/EpochInclusionToggle.css @@ -0,0 +1,4 @@ +.inspector button.epoch-inclusion-toggle{display:inline-flex;flex:0 0 23px;width:23px;min-height:23px;align-items:center;justify-content:center;gap:0;padding:3px;border:1px solid transparent;border-radius:4px;background:transparent;color:#397267} +.inspector button.epoch-inclusion-toggle[aria-pressed=false]{color:#945541;background:#f8eee9} +.inspector button.epoch-inclusion-toggle:hover{border-color:var(--border);background:var(--surface-subtle)} +.inspector button.epoch-inclusion-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px} diff --git a/workspace-app/src/components/EpochInclusionToggle.jsx b/workspace-app/src/components/EpochInclusionToggle.jsx new file mode 100644 index 0000000..8414678 --- /dev/null +++ b/workspace-app/src/components/EpochInclusionToggle.jsx @@ -0,0 +1,7 @@ +import {Check,X} from 'lucide-react'; +import './EpochInclusionToggle.css'; + +export default function EpochInclusionToggle({epoch,label,onToggle,disabled=false,className=''}){ + const included=epoch.curation?.included!==false; + return ; +} diff --git a/workspace-app/src/components/HierarchyTree.css b/workspace-app/src/components/HierarchyTree.css index 2a5a33c..152d426 100644 --- a/workspace-app/src/components/HierarchyTree.css +++ b/workspace-app/src/components/HierarchyTree.css @@ -23,9 +23,6 @@ .ht-leaf{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px} .ht-leaf>button:first-child{min-width:0;flex-wrap:wrap} .ht-leaf .annotation-row-pills{max-width:100%;flex-wrap:wrap} -.ht-leaf>button.ht-tag-action,.ht-leaf>button.ht-inclusion-action{width:auto;min-height:28px;padding:5px 6px;gap:4px;justify-content:center;font-size:10px;white-space:nowrap} +.ht-leaf>button.ht-tag-action{width:auto;min-height:28px;padding:5px 6px;gap:4px;justify-content:center;font-size:10px;white-space:nowrap} .ht-leaf>button.ht-tag-action{color:var(--accent);border:1px solid var(--border)} -.ht-leaf>button.ht-inclusion-action,.ht-inclusion-state{color:#467554;background:#edf5ef;border-radius:5px} -.ht-leaf>button.ht-inclusion-action.excluded,.ht-inclusion-state.excluded{color:#9b6255;background:#faf0ec} .ht-inclusion-state{font-size:10px;padding:5px} -.ht-inclusion-action[aria-pressed=false]{text-decoration:none} diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 359f9ab..246be46 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -1,6 +1,7 @@ +import EpochInclusionToggle from './EpochInclusionToggle.jsx'; import {revealWithin} from '../epochListScroll.js'; import {useCallback,useEffect,useRef,useState} from 'react'; -import {Check,X,Tag,Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; +import {Tag,Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; import {api,number,duration} from '../api.js'; import {treePageRequest} from '../pagedTreeRequest.js'; import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; @@ -98,7 +99,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.onTagEpoch&&}{props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.curation?.included===false?'Excluded':'Included'}}
      • ; + if(page.kind==='epochs')return
      • {props.onTagEpoch&&}{props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.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.css b/workspace-app/src/components/InspectionCellTree.css index ad39d0d..04530dc 100644 --- a/workspace-app/src/components/InspectionCellTree.css +++ b/workspace-app/src/components/InspectionCellTree.css @@ -23,7 +23,4 @@ .inspection-cell-tree .cell-tree-date>summary{padding:7px 6px} .cell-epoch-actions{padding:3px 5px}.cell-epoch-actions button{font-size:10px;padding:2px 5px} -.inspector .cell-tree-epoch>.epoch-analysis-toggle{flex:0 0 23px;justify-content:center;min-height:23px;border:1px solid transparent;border-radius:4px;color:#397267} -.inspector .cell-tree-epoch>.epoch-analysis-toggle[aria-pressed=false]{color:#945541;background:#f8eee9} -.inspector .cell-tree-epoch>.epoch-analysis-toggle:hover{border-color:var(--border);background:var(--surface-subtle)} .inspector .cell-tree-epoch.analysis-excluded>button>strong{color:var(--ink-muted)} diff --git a/workspace-app/src/components/InspectionCellTree.jsx b/workspace-app/src/components/InspectionCellTree.jsx index 5cac486..8d3f1f3 100644 --- a/workspace-app/src/components/InspectionCellTree.jsx +++ b/workspace-app/src/components/InspectionCellTree.jsx @@ -1,5 +1,5 @@ import {useEffect,useMemo,useRef,useState} from 'react'; -import {Check,X} from 'lucide-react'; +import EpochInclusionToggle from './EpochInclusionToggle.jsx'; import AnnotationIndicator from './AnnotationIndicator.jsx'; import {api,humanize,number} from '../api.js'; import {epochPageRequest} from '../epochBrowserSource.js'; @@ -22,7 +22,7 @@ function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disab {epoch.curation?.included===false&&×} - {onToggleInclusion&&} + {onToggleInclusion&&}
      )} {page.data&&
      {page.data.total?offset+1:0}–{Math.min(offset+60,page.data.total)} of {number(page.data.total)}
      } ; From 7d49db259b964cf45b8d0f9893bb0c814ef19ba4 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:05 -0700 Subject: [PATCH 4/9] Keep manually collapsed split-tree branches closed --- workspace-app/src/components/HierarchyTree.jsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 246be46..25dc9e7 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -79,15 +79,23 @@ export default function HierarchyTree(props){ },[state.pages,state.expanded,state.loading,state.error]); useEffect(()=>{if(!state.loading&&focusScroll.current){const node=container.current?.querySelector(`[data-epoch-uuid="${focusScroll.current}"]`);if(node){revealWithin(scroll.current,node);focusScroll.current=null;}}},[selected,state.pages,state.loading]); function toggle(branch){ - if(current.current.error)return; + if(current.current.error)return false; const key=hierarchyKey(branch.path),opened=current.current.expanded.some(path=>hierarchyKey(path)===key); if(opened){ + revealSelected.current=null;focusScroll.current=null; if(current.current.loadingPath&&pathContains(branch.path,current.current.loadingPath)){controller.current?.abort();serial.current++;callbacks.current.onStatus?.({loading:false,error:null});} commit(old=>({...collapseHierarchy(old,branch.path),loading:old.loadingPath&&pathContains(branch.path,old.loadingPath)?false:old.loading,loadingPath:old.loadingPath&&pathContains(branch.path,old.loadingPath)?null:old.loadingPath})); }else{ commit(old=>expandHierarchy(old,branch.path)); if(!current.current.pages.some(page=>hierarchyKey(page.path)===key))load({path:branch.path}); } + return !opened; + } + function collapseAll(){ + controller.current?.abort();serial.current++; + revealSelected.current=null;focusScroll.current=null; + commit(old=>({...old,expanded:[],loading:false,loadingPath:null})); + callbacks.current.onStatus?.({loading:false,error:null}); } function branchKeys(event,branch,opened){ if(event.key==='ArrowRight'){event.preventDefault();event.stopPropagation();if(!opened)toggle(branch);else event.currentTarget.closest('li')?.querySelector('ul button')?.focus({preventScroll:true});} @@ -101,7 +109,7 @@ export default function HierarchyTree(props){ {entries.map((item,index)=>{ if(page.kind==='epochs')return
    • {props.onTagEpoch&&}{props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.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.'}
      )}
    • ; + return
    • {opened&&(children?renderPage(children):
      {loading?'Loading branch…':'Branch is not loaded.'}
      )}
    • ; })} {!entries.length&&
    • No matching epochs.
    • } {(page.offset>0||page.has_more)&&
    • {page.offset+1}–{page.offset+entries.length} / {number(page.total)}
    • } @@ -113,6 +121,6 @@ export default function HierarchyTree(props){ {state.error&&
      {state.error}
      } {state.notice&&
      {state.notice}
      }
      {if(!scrollFrame.current)scrollFrame.current=requestAnimationFrame(()=>{scrollFrame.current=null;remember();});}}>{root?renderPage(root,true):!state.error&&

      Loading tree…

      }
      -
      Expand groups to inspect epochs. Grouping preserves the selection.
      +
      Expand groups to inspect epochs. Grouping preserves the selection.
      ; } From 80c1db6839cc0becf1824285711df9a06133abb7 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:00:32 -0700 Subject: [PATCH 5/9] Unify epoch views and add protocol-local multi-tag filters --- python/tests/test_workspace_annotations.py | 104 ++++++++++++++++++ python/workspace_api.py | 8 +- python/workspace_service.py | 63 +++++++++-- workspace-app/src/App.jsx | 5 +- .../src/components/EpochBrowserChrome.jsx | 8 +- .../src/components/HierarchyTree.css | 33 +++++- .../src/components/HierarchyTree.jsx | 7 +- .../src/components/InspectionCellTree.css | 24 ++++ .../src/components/InspectionCellTree.jsx | 9 +- workspace-app/src/components/Inspector.css | 4 + workspace-app/src/components/Inspector.jsx | 13 ++- .../src/components/ProtocolViewFilter.css | 20 ++++ .../src/components/ProtocolViewFilter.jsx | 15 +++ workspace-app/src/protocolViewFilter.js | 22 ++++ workspace-app/src/protocolViewFilter.test.js | 18 +++ 15 files changed, 320 insertions(+), 33 deletions(-) create mode 100644 workspace-app/src/components/ProtocolViewFilter.css create mode 100644 workspace-app/src/components/ProtocolViewFilter.jsx create mode 100644 workspace-app/src/protocolViewFilter.js create mode 100644 workspace-app/src/protocolViewFilter.test.js diff --git a/python/tests/test_workspace_annotations.py b/python/tests/test_workspace_annotations.py index a36432d..9dd49b2 100644 --- a/python/tests/test_workspace_annotations.py +++ b/python/tests/test_workspace_annotations.py @@ -221,6 +221,110 @@ def test_forged_cell_link_and_oversized_tag_sets_fail_closed(self): for raw in ('limit=true','limit=1.5','limit=-1','limit=1&limit=2','limit=%D9%A1'): with self.subTest(raw=raw):self.assertEqual(self.client.get('/api/annotation-tags?'+raw).status_code,400) + def test_protocol_local_tag_filter_matches_inherited_and_direct_tags_across_views_and_export(self): + base = '/api/protocols/' + self.service.protocol_id + original = copy.deepcopy(self.service.query_result(self.service.protocol_id)) + self.edit('cell', self.cell, ['selected']) + self.edit('epoch', self.second, ['other']) + summary = self.client.get(base + '?tag=selected').get_json() + self.assertEqual(summary['counts']['epochs'], 1) + self.assertEqual([cell['cell_uuid'] for cell in summary['cells']], [self.cell]) + page = self.client.get(base + '/epochs?tag=selected').get_json() + self.assertEqual([row['epoch_uuid'] for row in page['epochs']], [self.first]) + tree = self.client.get(base + '/tree?tag=selected&splits=cell') + self.assertEqual(tree.status_code, 200, tree.get_json()) + self.assertEqual(tree.get_json()['count'], 1) + self.assertEqual(tree.get_json()['children'][0]['epoch_uuids'], [self.first]) + fields = self.client.get(base + '/tree-fields?tag=selected') + self.assertEqual(fields.status_code, 200, fields.get_json()) + paged = self.client.post('/api/tree-pages', json={ + 'protocol_uuid': self.service.protocol_id, 'filters': {'tag': 'selected'}, + 'splits': 'cell'}, headers=self.headers).get_json() + self.assertEqual(paged['total_epochs'], 1) + leaf = self.client.post('/api/tree-pages', json={ + 'protocol_uuid': self.service.protocol_id, 'filters': {'tag': 'selected'}, + 'splits': 'cell', 'path': paged['branches'][0]['path'], + 'revision': paged['revision']}, headers=self.headers).get_json() + self.assertEqual([row['epoch_uuid'] for row in leaf['epochs']], [self.first]) + self.assertEqual(self.client.get(base + '/epochs?tagged=true').get_json()['total'], 2) + self.assertEqual(self.client.get(base + '/epochs?tag=Selected').get_json()['total'], 0) + exported = self.client.post(base + '/exports', json={ + 'query_revision': summary['query_revision'], 'filters': {'tag': 'selected'}}, headers=self.headers) + self.assertEqual(exported.status_code, 201, exported.get_json()) + response = self.client.get(exported.get_json()['download_url']) + package = json.loads(response.data); response.close() + self.assertEqual([row['epoch_uuid'] for row in package['epochs']], [self.first]) + self.assertEqual(package['recipe']['options']['filters'], {'tag': 'selected'}) + self.assertEqual(self.service.query_result(self.service.protocol_id), original) + + def test_protocol_tag_predicate_all_any_and_negation_keep_tree_and_exports_consistent(self): + base = '/api/protocols/' + self.service.protocol_id + original = copy.deepcopy(self.service.query_result(self.service.protocol_id)) + self.edit('cell', self.cell, ['good']) + self.edit('epoch', self.first, ['inspect']) + self.edit('epoch', self.second, ['reject']) + def rule(tag, field='annotations/effective/tags'): + return {'field': field, 'operator': 'contains', 'value': tag} + cases = [({'all': [rule('good'), rule('inspect'), {'not': rule('reject')}]}, [self.first]), + ({'any': [rule('good'), rule('reject')]}, [self.first, self.second]), + ({'all': [rule('good', 'annotations/epoch/tags')]}, []), + ({'all': [{'not': rule('reject')}]}, [self.first])] + for predicate, expected in cases: + with self.subTest(predicate=predicate): + filters = {'tag_predicate': json.dumps(predicate)} + response = self.client.get(base, query_string=filters) + self.assertEqual(response.status_code, 200, response.get_json()) + summary = response.get_json() + self.assertEqual(summary['counts']['epochs'], len(expected)) + page = self.client.get(base + '/epochs', query_string=filters).get_json() + self.assertEqual([row['epoch_uuid'] for row in page['epochs']], expected) + tree = self.client.get(base + '/tree', query_string={**filters, 'splits': 'cell'}).get_json() + self.assertEqual(tree['count'], len(expected)) + paged = self.client.post('/api/tree-pages', json={ + 'protocol_uuid': self.service.protocol_id, 'filters': filters, + 'splits': 'cell'}, headers=self.headers).get_json() + self.assertEqual(paged['total_epochs'], len(expected)) + if expected: + exported = self.client.post(base + '/exports', json={ + 'query_revision': summary['query_revision'], 'filters': filters}, headers=self.headers) + self.assertEqual(exported.status_code, 201, exported.get_json()) + download = self.client.get(exported.get_json()['download_url']) + package = json.loads(download.data); download.close() + self.assertEqual([row['epoch_uuid'] for row in package['epochs']], expected) + self.assertEqual(self.service.query_result(self.service.protocol_id), original) + + def test_protocol_tag_predicate_rejects_non_tag_fields_and_invalid_ast(self): + base = '/api/protocols/' + self.service.protocol_id + predicates = ['{', 'null', json.dumps({'field': 'parameters/example', 'operator': 'eq', 'value': 1}), + json.dumps({'field': 'annotations/effective/tags', 'operator': 'contains', 'value': 3}), + json.dumps({'field': 'annotations/effective/tags', 'operator': 'unknown', 'value': 'x'}), + json.dumps({'all': [], 'extra': True}), json.dumps({'not': {'all': []}, 'extra': True})] + for predicate in predicates: + with self.subTest(predicate=predicate): + filters = {'tag_predicate': predicate} + self.assertEqual(self.client.get(base + '/epochs', query_string=filters).status_code, 400) + self.assertEqual(self.client.post('/api/tree-pages', json={ + 'protocol_uuid': self.service.protocol_id, 'filters': filters}, headers=self.headers).status_code, 400) + + def test_tag_filter_tree_fields_refresh_after_annotation_change_and_reject_malformed_filters(self): + base = '/api/protocols/' + self.service.protocol_id + self.edit('epoch', self.first, ['selected']) + before = self.service._tree_fields(self.service.protocol_id, {'tag': 'selected'}) + self.edit('epoch', self.first, remove=['selected'], revision=1) + self.edit('epoch', self.second, ['selected']) + after = self.service._tree_fields(self.service.protocol_id, {'tag': 'selected'}) + self.assertNotEqual(before[1], after[1]) + self.assertEqual([row['epoch_uuid'] for row in self.service.filtered_rows( + self.service.protocol_id, {'tag': 'selected'})], [self.second]) + for query in ('tagged=false', 'tagged=1', 'tag=', 'tag=%20selected', 'tag=selected%0A', 'tag=selected&tag=other', 'tagged=true&tagged=false'): + with self.subTest(query=query): + self.assertEqual(self.client.get(base + '/epochs?' + query).status_code, 400) + for filters in ({'tag': True}, {'tagged': True}, {'tag': ['selected']}, {'tagged': ''}): + with self.subTest(filters=filters): + response = self.client.post('/api/tree-pages', json={ + 'protocol_uuid': self.service.protocol_id, 'filters': filters}, headers=self.headers) + self.assertEqual(response.status_code, 400) + def test_protocol_export_freezes_annotations_and_stale_tags_reject_old_revision(self): base='/api/protocols/'+self.service.protocol_id previous=self.client.get(base).get_json()['query_revision'] diff --git a/python/workspace_api.py b/python/workspace_api.py index 332475b..deebe32 100644 --- a/python/workspace_api.py +++ b/python/workspace_api.py @@ -129,10 +129,12 @@ def create_app(project_dir, retinanalysis_dir, *, service=None, store=None, expl app.extensions["curation_store"] = store def filters(allowed=()): - if set(request.args) - {"cell_uuid", "cell_type", "group_label", "offset", "limit", "splits"} - set(allowed): + if any(len(request.args.getlist(key)) != 1 for key in ('tag', 'tagged', 'tag_predicate') if key in request.args): + raise ValueError('Tag filters must be specified once') + if set(request.args) - {"cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate", "offset", "limit", "splits"} - set(allowed): raise ValueError("Unknown query filter; no unrestricted fallback was applied") - return {key: request.args[key] for key in ("cell_uuid", "cell_type", "group_label") - if request.args.get(key)} + return {key: request.args[key] for key in ("cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate") + if key in request.args} def state(protocol_uuid): result = service.query_result(protocol_uuid) diff --git a/python/workspace_service.py b/python/workspace_service.py index 0630993..c34e7ea 100644 --- a/python/workspace_service.py +++ b/python/workspace_service.py @@ -52,16 +52,40 @@ def _fingerprint(epoch): def validate_filters(filters): filters = {} if filters is None else filters - if not isinstance(filters, dict) or set(filters) - {'cell_uuid', 'cell_type', 'group_label'}: - raise ValueError('Unsupported filter; use cell_uuid, cell_type, or group_label') + if not isinstance(filters, dict) or set(filters) - {'cell_uuid', 'cell_type', 'group_label', 'tag', 'tagged', 'tag_predicate'}: + raise ValueError('Unsupported filter; use cell_uuid, cell_type, group_label, tag, tagged, or tag_predicate') if any(not isinstance(value, str) for value in filters.values()): raise ValueError('Filter values must be text') + if 'tag' in filters: + from workspace_annotations import text + text(filters['tag']) + if 'tagged' in filters and filters['tagged'] != 'true': + raise ValueError('The tagged filter must be the text true') + if 'tag_predicate' in filters: + predicate = validate_tag_filter_predicate(filters['tag_predicate']) + filters = {**filters, 'tag_predicate': json.dumps(predicate, sort_keys=True, separators=(',', ':'), allow_nan=False)} cleaned = {k: v for k, v in filters.items() if v} if 'cell_uuid' in cleaned: cleaned['cell_uuid'] = _uuid(cleaned['cell_uuid']) return cleaned +TAG_FILTER_FIELDS = ('annotations/cell/tags', 'annotations/epoch/tags', 'annotations/effective/tags') + + +def validate_tag_filter_predicate(value): + """Use the regular bounded predicate grammar, limited to shared tag arrays.""" + if not isinstance(value, str) or len(value) > 65536: + raise ValueError('Tag predicate must be JSON text up to 65536 characters') + try: + predicate = json.loads(value) + except (ValueError, RecursionError) as error: + raise ValueError('Malformed tag predicate JSON') from error + from workspace_predicates import validate + return validate(predicate, {'fields': [{'id': key} for key in TAG_FILTER_FIELDS]}, + {'tag_schema': {key: ['example tag'] for key in TAG_FILTER_FIELDS}}) + + def bounded_window(start, count, total, maximum=100000): if isinstance(start, bool) or isinstance(count, bool) or not isinstance(start, int) or not isinstance(count, int): raise ValueError('Sample offset and count must be integers') @@ -581,8 +605,32 @@ def filtered_rows(self, protocol_uuid, filters=None): result = self.query_result(protocol_uuid) curation = self._curation(protocol_uuid) rows = [self._decorate(self.rows[member['uuid']], curation) for member in result['epochs']] - return sorted((row for row in rows if all(row.get(k) == v for k, v in filters.items())), - key=lambda row: (row['date'], row['start_time'], row['epoch_uuid'])) + return self._filter_rows(rows, filters) + + def _filter_rows(self, rows, filters): + """Narrow the current dataset without changing membership or annotations. + + Shared cell tags inherit to epochs. Protocol curation tags are a distinct + scope and do not satisfy these shared annotation filters. + """ + ordinary = {key: value for key, value in filters.items() if key not in {'tag', 'tagged', 'tag_predicate'}} + rows = [row for row in rows if all(row.get(key) == value for key, value in ordinary.items())] + if any(key in filters for key in ('tag', 'tagged', 'tag_predicate')): + shared = getattr(self, 'shared_annotations', None) + annotations = shared.for_epochs(rows) if shared else {} + from workspace_predicates import matches + predicate = validate_tag_filter_predicate(filters['tag_predicate']) if 'tag_predicate' in filters else None + selected = [] + for row in rows: + tags = {chip['tag'] for chip in annotations.get(row['epoch_uuid'], {}).get('effective_tags', [])} + if ('tag' not in filters or filters['tag'] in tags) and ('tagged' not in filters or tags): + annotation = annotations.get(row['epoch_uuid'], {}) + current = {field: sorted({chip['tag'] for chip in annotation.get(scope, [])}) + for field, scope in zip(TAG_FILTER_FIELDS, ('cell_tags', 'epoch_tags', 'effective_tags'))} + if predicate is None or matches(predicate, current): + selected.append(row) + rows = selected + return sorted(rows, key=lambda row: (row['date'], row['start_time'], row['epoch_uuid'])) @staticmethod def _counts(rows): @@ -681,7 +729,9 @@ def _tree_fields(self, protocol_uuid, filters=None): binding = self.binding(protocol_uuid) if protocol_uuid else None scope = self.source_scope() if protocol_uuid is None else None scope_revision = scope['revision'] if scope else None - key = (protocol_uuid, binding['version'] if binding else 0, scope_revision, tuple(sorted(filters.items()))) + shared = getattr(self, 'shared_annotations', None) + tag_revision = shared.snapshot()['revision'] if shared and any(key in filters for key in ('tag', 'tagged', 'tag_predicate')) else None + key = (protocol_uuid, binding['version'] if binding else 0, scope_revision, tuple(sorted(filters.items())), tag_revision) if key in cache: result = cache.pop(key) cache[key] = result # Most recently used, without recomputation. @@ -713,8 +763,7 @@ def _tree_rows(self, protocol_uuid, filters=None): self._ready() filters = validate_filters(filters) active = set(self.source_scope()['active_source_revisions']) - return sorted((row for row in self.rows.values() if row['source_sha256'] in active and all(row.get(key) == value for key, value in filters.items())), - key=lambda row: (row['date'], row['start_time'], row['epoch_uuid'])) + return self._filter_rows([row for row in self.rows.values() if row['source_sha256'] in active], filters) def validate_tree_splits(self, protocol_uuid, splits): fields = self.tree_fields(protocol_uuid)['fields'] diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx index 7bafbb8..151c8cd 100644 --- a/workspace-app/src/App.jsx +++ b/workspace-app/src/App.jsx @@ -1,3 +1,4 @@ +import {tagFilterLabel} from './protocolViewFilter.js'; import H5Inbox from './components/H5Inbox.jsx'; import ProjectClosedNotice from './components/ProjectClosedNotice.jsx'; import AppUpdates from './components/AppUpdates.jsx'; @@ -73,7 +74,7 @@ function Protocol({id,revision,onChange,onTagFilter,initialRecipe,initialInspect function filter(key,value){setResult(null);setError('');setFilters(old=>{const next={...old};if(value)next[key]=value;else delete next[key];return next;});} async function refreshQuery(){setRefreshing(true);setError('');try{setDiff(await api(`/protocols/${id}/refresh`,{method:'POST',body:{filters}}));protocol.reload();onChange();}catch(e){setError(e.message);}finally{setRefreshing(false);}} async function saveExport(){setExporting(true);setError('');try{const r=await api(`/protocols/${id}/exports`,{method:'POST',body:{format,name:exportName || data.definition.name,review_policy:policy,filters,query_revision:data.query_revision,split_order:splitOrder.join(', ')}});if(!validExportReceipt(r,format)){onChange();throw new Error('The server did not return a complete export receipt. Check this protocol’s Exports and Activity & logs before retrying.');}setResult(r);onChange();}catch(e){setError(e.message);}finally{setExporting(false);}} - const exportPanel=data&&(<>
      {setFormat(value);setResult(null);setError('');}}/>

      {EXPORT_FORMATS[format]?.description || 'Choose a supported output format to continue. The saved export keeps its original format.'}

      Export scope · {number(counts.cells)} matching cells

      Uses this protocol’s saved query and current filters. Focusing a cell or checking bulk-action targets does not restrict this export.

      {filters.cell_type || 'All cell types'}{filters.group_label ? `Group: ${filters.group_label}` : 'All source groups'}{filters.cell_uuid&&Cell: {datedCellLabel(data.cells?.find(c=>c.cell_uuid===filters.cell_uuid) || {},true)}}
      Matching epochs
      {number(counts.epochs)}
      Included
      {number(counts.included)}
      Excluded
      {number(counts.excluded)}
      Already in saved exports
      {number(counts.exported ?? 0)}
      {policy==='approved_only'&&
      Not marked reviewed
      {number(held)}
      }
      Ready to export
      {exportBlocked?'Source update needed':Number.isFinite(eligible)?number(eligible):'Loading…'}
      {exportBlocked?

      This retained dataset includes sources excluded from queries. Open Data stores and propagate the proposed changes before exporting.

      :eligible===0?

      {policy==='approved_only'&&counts.included>0?'No included epochs are marked reviewed. Choose “Included epochs” to export without a review requirement, or mark review status during inspection.':'No epochs are included in this query. Change the query scope or include the epochs you want to export.'}

      :

      {policy==='include_unreviewed'?'All included epochs are eligible; review is not required.':'Only included epochs marked reviewed are eligible.'} The server rechecks membership before saving. Each export remains a separate saved revision.

      }
      {error&&
      {error}
      }{result&&
      Export saved with its query and exact membership. {result.download_url&&{`Download ${exportDownloadLabel(result.format)}`}}
      }
      Previous exports
      ); + const exportPanel=data&&(<>
      {setFormat(value);setResult(null);setError('');}}/>

      {EXPORT_FORMATS[format]?.description || 'Choose a supported output format to continue. The saved export keeps its original format.'}

      Export scope · {number(counts.cells)} matching cells

      Uses this protocol’s saved query and current filters. Focusing a cell or checking bulk-action targets does not restrict this export.

      {filters.cell_type || 'All cell types'}{tagFilterLabel(filters)&&{tagFilterLabel(filters)}}{filters.group_label ? `Group: ${filters.group_label}` : 'All source groups'}{filters.cell_uuid&&Cell: {datedCellLabel(data.cells?.find(c=>c.cell_uuid===filters.cell_uuid) || {},true)}}
      Matching epochs
      {number(counts.epochs)}
      Included
      {number(counts.included)}
      Excluded
      {number(counts.excluded)}
      Already in saved exports
      {number(counts.exported ?? 0)}
      {policy==='approved_only'&&
      Not marked reviewed
      {number(held)}
      }
      Ready to export
      {exportBlocked?'Source update needed':Number.isFinite(eligible)?number(eligible):'Loading…'}
      {exportBlocked?

      This retained dataset includes sources excluded from queries. Open Data stores and propagate the proposed changes before exporting.

      :eligible===0?

      {policy==='approved_only'&&counts.included>0?'No included epochs are marked reviewed. Choose “Included epochs” to export without a review requirement, or mark review status during inspection.':tagFilterLabel(filters)?'No epochs match this tag filter. Clear or change the filter to export matching epochs.':'No epochs are included in this query. Change the query scope or include the epochs you want to export.'}

      :

      {policy==='include_unreviewed'?'All included epochs are eligible; review is not required.':'Only included epochs marked reviewed are eligible.'} The server rechecks membership before saving. Each export remains a separate saved revision.

      }
      {error&&
      {error}
      }{result&&
      Export saved with its query and exact membership. {result.download_url&&{`Download ${exportDownloadLabel(result.format)}`}}
      }
      Previous exports
      ); const exportFooter=(
      Logged in project activity
      ); return
      {treeLayout.save.status==='saving'?'Saving tree…':treeLayout.save.status==='error'?'Tree not saved':treeLayout.save.version>0?'Tree saved':''}{Object.keys(filters).length?`${Object.keys(filters).length} active filters`:'All matching cells'} -
      {treeLayout.save.status==='error'&&
      Tree layout could not be saved: {treeLayout.save.error}
      }{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel}}{tab==='inspect'?setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:
      PROTOCOL WORKSPACE

      {humanize(data.definition?.name)}

      {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}

      {initialRecipe&&
      Reused saved query; matching the current catalog. Previous export unchanged.
      }{diff&&
      {diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)}<>{diff.candidate_revision_uuid&&}
      }{error&&tab!=='export'&&
      {error}
      }Loading dataset summary…
      }>
      Filter results{Object.keys(filters).length>0&&}Group labels are original metadata, not inferred drug phases.
      <>{suggestionsError&&
      New-data suggestions could not load.
      }{suggestion&&}

      Cells in this protocol

      }}
      ; +
      {treeLayout.save.status==='error'&&
      Tree layout could not be saved: {treeLayout.save.error}
      }{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel}}{tab==='inspect'?{setFilters(next);setResult(null);setError('');}} onTagFilter={onTagFilter} key={browseRequest} initialEpochUuid={initialEpoch} initialNavigation={inspectorSession.current} onSessionChange={rememberInspector} onQC={onQC} protocol={data} splitRecipe={splitOrder} onSplitChange={setSplitOrder} cellScope={scope} filters={filters} revision={revision} onChange={onChange} onBack={()=>setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:
      PROTOCOL WORKSPACE

      {humanize(data.definition?.name)}

      {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}

      {initialRecipe&&
      Reused saved query; matching the current catalog. Previous export unchanged.
      }{diff&&
      {diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)}<>{diff.candidate_revision_uuid&&}
      }{error&&tab!=='export'&&
      {error}
      }Loading dataset summary…
      }>
      Filter results{Object.keys(filters).length>0&&}Group labels are original metadata, not inferred drug phases.
      <>{suggestionsError&&
      New-data suggestions could not load.
      }{suggestion&&}

      Cells in this protocol

      }}
    ; } function ImportPage({projectId,protocols,sources,onChange,onBack,jobs,suggestions=[],approvedHistory=[],suggestionsError,onRetrySuggestions,onReviewCandidate,onProtocol,queue}) { const jobRecords=Array.isArray(jobs.data?.jobs)?jobs.data.jobs:[]; diff --git a/workspace-app/src/components/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx index 3dc7498..ad587d0 100644 --- a/workspace-app/src/components/EpochBrowserChrome.jsx +++ b/workspace-app/src/components/EpochBrowserChrome.jsx @@ -2,8 +2,8 @@ import {Activity,GitBranch,MessageCircle,PanelRightClose,PanelRightOpen,ArrowLef 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}){ - return
    {!designMode&&<>}{children}{onExport&&}
    ; +export function EpochBrowserToolbar({designMode=false,designDisabled=false,onBrowse,onDesign,onTags,metadataOpen,onToggleMetadata,onExport,exportDisabled=false,actions=[],children,treeControlsInPane=false,filterControl=null}){ + return
    {(!treeControlsInPane||designMode)&&}{!designMode&&<>{filterControl}}{children}{onExport&&}
    ; } export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ return targets.length>0&&
    {number(targets.length)} epochs selected
    ; @@ -11,6 +11,6 @@ export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ export function EpochNavigation({position,total,loading=false,disabled=false,onMove}){ return
    {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}){ - return

    {treeMode?'Shared protocol tree':'Date · cell · epochs'}

    {onTreeMode&&
    }
    ; +export function EpochListHeading({treeMode=false,onTreeMode,onDesign,designDisabled=false}){ + return
    {onTreeMode&&<>}
    {onDesign&&}
    ; } diff --git a/workspace-app/src/components/HierarchyTree.css b/workspace-app/src/components/HierarchyTree.css index 152d426..f238ffe 100644 --- a/workspace-app/src/components/HierarchyTree.css +++ b/workspace-app/src/components/HierarchyTree.css @@ -20,9 +20,36 @@ .ht-splits{gap:6px 10px;padding:10px 14px;line-height:1.6} .ht-splits>span{min-width:0;overflow-wrap:anywhere} -.ht-leaf{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px} +.ht-leaf{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:4px} .ht-leaf>button:first-child{min-width:0;flex-wrap:wrap} .ht-leaf .annotation-row-pills{max-width:100%;flex-wrap:wrap} -.ht-leaf>button.ht-tag-action{width:auto;min-height:28px;padding:5px 6px;gap:4px;justify-content:center;font-size:10px;white-space:nowrap} -.ht-leaf>button.ht-tag-action{color:var(--accent);border:1px solid var(--border)} .ht-inclusion-state{font-size:10px;padding:5px} + +/* Grouping changes the hierarchy, while rows match the Epochs browser. */ +.hierarchy-tree .ht-scroll{padding:8px 8px 16px} +.hierarchy-tree .ht-children{margin-left:16px;padding-left:0;border:0} +.hierarchy-tree .ht-children>li::before{display:none} +.hierarchy-tree .ht-branch>button{min-height:32px;padding:7px 8px;gap:8px;align-items:flex-start;border-radius:5px;font-size:12px;line-height:18px} +.hierarchy-tree .ht-branch>button>.ht-folder{display:none} +.hierarchy-tree .ht-branch>button>svg{margin-top:2px;color:var(--ink-secondary)} +.hierarchy-tree .ht-branch>button:hover, +.hierarchy-tree .ht-leaf:hover{background:var(--surface-subtle)} +.hierarchy-tree .ht-branch>button.selected{background:var(--accent-soft,#eee6f5);box-shadow:inset 3px 0 var(--accent,#806497);color:var(--accent)} +.hierarchy-tree .ht-branch>button:focus-visible, +.hierarchy-tree .ht-leaf>button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +.hierarchy-tree .ht-value{line-height:18px} +.hierarchy-tree .ht-field{font-size:11px} +.hierarchy-tree .ht-combination{gap:6px} +.hierarchy-tree .ht-combination>span{line-height:1.5} +.hierarchy-tree .ht-count{margin-top:0;font-size:11px;line-height:18px} +.hierarchy-tree .ht-leaf{min-height:29px;gap:4px;padding:2px 5px;border-radius:5px} +.hierarchy-tree .ht-leaf>button:first-child{min-height:25px;padding:2px 5px;gap:6px;flex-wrap:nowrap;font-size:11px;font-weight:550;line-height:18px} +.hierarchy-tree .ht-leaf>button:first-child>svg{display:none} +.hierarchy-tree .ht-leaf .ht-value{font-size:11px;font-variant-numeric:tabular-nums} +.hierarchy-tree .ht-leaf:has(>button.selected){background:var(--accent-soft,#eee6f5);box-shadow:inset 3px 0 var(--accent,#806497)} +.hierarchy-tree .ht-leaf>button.selected{background:transparent;color:var(--ink)} +.hierarchy-tree .ht-leaf .annotation-row-pills{max-width:96px;gap:3px;flex-wrap:nowrap;line-height:1.2} +.hierarchy-tree .ht-leaf>button.epoch-inclusion-toggle{min-height:23px;padding:3px;font-weight:550} +.hierarchy-tree .ht-pagination{margin-top:4px;padding:6px 5px} + +.hierarchy-tree .ht-leaf:has(>.epoch-inclusion-toggle[aria-pressed=false])>button:first-child>.ht-value{color:var(--ink-muted)} diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index 25dc9e7..b96b324 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -1,14 +1,13 @@ import EpochInclusionToggle from './EpochInclusionToggle.jsx'; import {revealWithin} from '../epochListScroll.js'; import {useCallback,useEffect,useRef,useState} from 'react'; -import {Tag,Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; +import {Activity,ArrowLeft,ArrowRight,ChevronDown,ChevronRight,Folder,FolderOpen,LoaderCircle,RefreshCw} from 'lucide-react'; import {api,number,duration} from '../api.js'; import {treePageRequest} from '../pagedTreeRequest.js'; import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,readableField} from '../treeBranchPresentation.js'; import {datedCellLabel} from '../recordingIdentity.js'; import {hierarchyKey,pathContains,mergeHierarchyPage,collapseHierarchy,expandHierarchy,hierarchySnapshot,hierarchyRestore,cancelUnloadedExpansion} from '../hierarchyTreeState.js'; import './HierarchyTree.css'; -import AnnotationIndicator from './AnnotationIndicator.jsx'; const blank=()=>({pages:[],expanded:[],loading:true,error:null,loadingPath:[],notice:''}); export default function HierarchyTree(props){ @@ -107,9 +106,9 @@ 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.onTagEpoch&&}{props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.curation?.included===false?'Excluded':'Included'}}
    • ; + if(page.kind==='epochs')return
    • {props.onToggleInclusion&&}{!props.onToggleInclusion&&{item.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.'}
      )}
    • ; + return
    • {opened&&(children?renderPage(children):
      {loading?'Loading branch…':'Branch is not loaded.'}
      )}
    • ; })} {!entries.length&&
    • No matching epochs.
    • } {(page.offset>0||page.has_more)&&
    • {page.offset+1}–{page.offset+entries.length} / {number(page.total)}
    • } diff --git a/workspace-app/src/components/InspectionCellTree.css b/workspace-app/src/components/InspectionCellTree.css index 04530dc..79c5af2 100644 --- a/workspace-app/src/components/InspectionCellTree.css +++ b/workspace-app/src/components/InspectionCellTree.css @@ -24,3 +24,27 @@ .cell-epoch-actions{padding:3px 5px}.cell-epoch-actions button{font-size:10px;padding:2px 5px} .inspector .cell-tree-epoch.analysis-excluded>button>strong{color:var(--ink-muted)} + +/* Both navigation views use the same compact rows and disclosure language. */ +.inspection-cell-tree{padding-block:8px;color:var(--ink)} +.inspection-cell-tree .cell-tree-date{margin-bottom:4px} +.inspection-cell-tree .cell-tree-cell{margin-left:16px} +.inspection-cell-tree .cell-tree-date>summary, +.inspection-cell-tree .cell-tree-cell>summary{position:relative;display:block;min-height:32px;padding:7px 8px 7px 28px;border:0;border-radius:5px;background:transparent;font-size:12px;line-height:18px;list-style:none;color:var(--ink)} +.inspection-cell-tree summary::-webkit-details-marker{display:none} +.inspection-cell-tree summary::before{content:'';position:absolute;left:11px;top:12px;width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);color:var(--ink-secondary)} +.inspection-cell-tree details[open]>summary::before{top:11px;transform:rotate(45deg)} +.inspection-cell-tree summary:hover{background:var(--surface-subtle)} +.inspection-cell-tree summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +.inspection-cell-tree .cell-tree-cell>summary.selected-cell{background:var(--accent-soft,#eee6f5);box-shadow:inset 3px 0 var(--accent,#806497);color:var(--accent)} +.inspector .inspection-cell-tree .cell-tree-epoch{min-height:29px;margin-left:16px;padding:2px 5px!important;border:0;border-radius:5px} +.inspector .inspection-cell-tree .cell-tree-epoch:hover{background:var(--surface-subtle)} +.inspector .inspection-cell-tree .cell-tree-epoch.active, +.inspector .inspection-cell-tree .cell-tree-epoch.bulk-selected{background:var(--accent-soft,#eee6f5);box-shadow:inset 3px 0 var(--accent,#806497);border:0} +.inspector .inspection-cell-tree .cell-tree-epoch>button{padding:2px 5px;min-height:25px;border-radius:4px} +.inspector .inspection-cell-tree .cell-tree-epoch>button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +.inspector .inspection-cell-tree .cell-tree-epoch strong{color:var(--ink);font-size:11px;font-weight:550;font-variant-numeric:tabular-nums} +.inspector .inspection-cell-tree .cell-tree-epoch time{font-size:11px;font-variant-numeric:tabular-nums} +.inspector .inspection-cell-tree .cell-tree-epoch .annotation-row-pills{gap:3px;max-width:96px;line-height:1.2} + +.inspector .inspection-cell-tree .cell-tree-epoch.analysis-excluded strong{color:var(--ink-muted)} diff --git a/workspace-app/src/components/InspectionCellTree.jsx b/workspace-app/src/components/InspectionCellTree.jsx index 8d3f1f3..64fd151 100644 --- a/workspace-app/src/components/InspectionCellTree.jsx +++ b/workspace-app/src/components/InspectionCellTree.jsx @@ -1,6 +1,5 @@ import {useEffect,useMemo,useRef,useState} from 'react'; import EpochInclusionToggle from './EpochInclusionToggle.jsx'; -import AnnotationIndicator from './AnnotationIndicator.jsx'; import {api,humanize,number} from '../api.js'; import {epochPageRequest} from '../epochBrowserSource.js'; import {epochSelectionRange,toggleEpochSelection,mergeEpochSelection} from '../epochSelection.js'; @@ -19,8 +18,8 @@ function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disab {offset+index+1} {humanize(epoch.protocol_name?.split('.').at(-1))||'—'} - - {epoch.curation?.included===false&&×} + + {onToggleInclusion&&}
    )} @@ -30,7 +29,7 @@ function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disab function CellBranch({cell,dateOpen,onSelectCell,selectedCell,...props}){ const [open,setOpen]=useState(false); return
    setOpen(event.currentTarget.open)}> - onSelectCell(cell)}>{cell.label||cell.cell_label||'Unlabeled cell'} + onSelectCell(cell)}>{cell.label||cell.cell_label||'Unlabeled cell'} {dateOpen&&open&&}
    ; } @@ -83,5 +82,5 @@ export default function InspectionCellTree({cells,targets,setTargets,disabled,on }catch(error){if(error.name!=='AbortError')setError(error.message);} finally{setSelecting(false);} } - return
    {error&&

    {error}

    }{selecting&&

    Selecting epoch range…

    }{dates.map(group=>)}
    ; + return
    {error&&

    {error}

    }{selecting&&

    Selecting epoch range…

    }{!dates.length&&

    No epochs match the current filters.

    }{dates.map(group=>)}
    ; } diff --git a/workspace-app/src/components/Inspector.css b/workspace-app/src/components/Inspector.css index 6d2d30d..3be4455 100644 --- a/workspace-app/src/components/Inspector.css +++ b/workspace-app/src/components/Inspector.css @@ -25,3 +25,7 @@ .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} .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} diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 40c3a51..8fd076c 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -1,3 +1,5 @@ +import {clearTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; +import ProtocolViewFilter from './ProtocolViewFilter.jsx'; import SelectionMaskDialog from './SelectionMaskDialog.jsx'; import {NavigationLoadingProvider,NavigationLoadingNotice} from './NavigationLoading.jsx'; import StableContent from './StableContent.jsx'; @@ -106,7 +108,7 @@ function ScientificContext({epoch}) { ; } -function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revision,onChange,onBack,onImport,onStores,onExport,splitRecipe=['date','cell','block'],onSplitChange,initialNavigation=null,onSessionChange,onQC,onTagFilter}) { +function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revision,onChange,onBack,onImport,onStores,onExport,splitRecipe=['date','cell','block'],onSplitChange,initialNavigation=null,onSessionChange,onQC,onTagFilter,onFilterChange}) { const id=protocol.definition.protocol_uuid; const requestedCellFocus=filters?.cell_uuid&&filters.cell_uuid!==cellScope?null:(cellScope || null); const [focusCell,setFocusCell]=useState(initialNavigation&&Object.hasOwn(initialNavigation,'focusCell')?initialNavigation.focusCell:requestedCellFocus); @@ -277,12 +279,13 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi 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
    - {setDesignMode(false);setTreeMode(false);setTreeOpen(true);}} onDesign={()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);}} onTags={openTagsForSelection} metadataOpen={metadataOpen} onToggleMetadata={()=>toggleMetadata(!metadataOpen)} actions={[ + } designMode={designMode} onBrowse={()=>{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}, ]}> {focusCell&&} + {!designMode&&tagFilterLabel(filters)&&{tagFilterLabel(filters)}} {!designMode&&<> @@ -307,18 +310,18 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi
    {treeOpen&&} {treeOpen&&changePane('tree',value)} onCommit={value=>savePane('tree',value)}/>} - {designMode?curate({included},'focused',item.epoch_uuid)} onTagEpoch={(uuid,item)=>{focusTreeEpoch(uuid,item);setTargets([]);setTagFocus(value=>value+1);}} 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?<> + {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?<>

    {datedCellLabel(focusedEpoch)}

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

    diff --git a/workspace-app/src/components/ProtocolViewFilter.css b/workspace-app/src/components/ProtocolViewFilter.css new file mode 100644 index 0000000..2c3ade1 --- /dev/null +++ b/workspace-app/src/components/ProtocolViewFilter.css @@ -0,0 +1,20 @@ +.protocol-view-filter{position:relative;flex-shrink:0} +.protocol-view-filter>button{font-size:12px;padding:6px 9px} +.protocol-view-filter>button.active{color:var(--accent);background:var(--surface-subtle)} +.filter-active-dot{width:5px;height:5px;border-radius:50%;background:var(--accent)} +.protocol-view-filter-panel{position:absolute;right:0;top:calc(100% + 8px);z-index:60;width:min(310px,calc(100vw - 32px));padding:16px;background:var(--surface,#fff);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 28px #30253626} +.protocol-view-filter-panel header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px} +.protocol-view-filter-panel p,.protocol-view-filter-panel small{font-size:12px;color:var(--ink-secondary);line-height:1.5} +.protocol-view-filter-panel label{display:grid;gap:6px;font-size:12px;margin:14px 0} +.protocol-view-filter-panel input,.protocol-view-filter-panel select{width:100%;min-width:0} +.protocol-view-filter-panel footer{display:flex;justify-content:flex-end;gap:8px;margin-top:18px} +.protocol-view-filter-panel footer button{font-size:12px} +.inspection-filter-summary{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--accent);min-width:0;overflow-wrap:anywhere} +.inspection-filter-summary button{font-size:11px;padding:3px 6px} +.protocol-view-filter-panel{width:min(560px,calc(100vw - 32px));max-height:calc(100vh - 200px);overflow-y:auto} +.tag-filter-logic{display:flex;align-items:center;gap:10px;margin:14px 0;font-size:12px} +.tag-filter-logic select{width:90px} +.tag-filter-rule{display:grid;grid-template-columns:125px 85px minmax(100px,1fr) 28px;gap:6px;align-items:center;margin:10px 0} +.tag-filter-rule input,.tag-filter-rule select{font-size:12px;padding:7px;min-width:0} +.tag-filter-rule>button{padding:5px;justify-content:center} +@media(max-width:650px){.tag-filter-rule{grid-template-columns:1fr 85px 28px}.tag-filter-rule input{grid-row:2;grid-column:1/3}.tag-filter-rule>button{grid-column:3;grid-row:1/3}} diff --git a/workspace-app/src/components/ProtocolViewFilter.jsx b/workspace-app/src/components/ProtocolViewFilter.jsx new file mode 100644 index 0000000..affaaaf --- /dev/null +++ b/workspace-app/src/components/ProtocolViewFilter.jsx @@ -0,0 +1,15 @@ +import {useState} from 'react'; +import {ListFilter,X,Plus,Trash2} from 'lucide-react'; +import {useResource} from '../api.js'; +import {readTagRules,compileTagRules,clearTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; +import './ProtocolViewFilter.css'; +const blank=()=>({scope:'effective',comparison:'is',value:''}); +export default function ProtocolViewFilter({filters={},onChange,revision,disabled=false}){ + const [open,setOpen]=useState(false),[mode,setMode]=useState('all_epochs'),[logic,setLogic]=useState('all'),[rules,setRules]=useState([blank()]),[query,setQuery]=useState(''),[error,setError]=useState(''); + const active=!!tagFilterLabel(filters); + const options=useResource(open&&mode==='rules'?`/annotation-tags?q=${encodeURIComponent(query)}&limit=12`:null,revision,120); + 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&&}
    }
    ; +} diff --git a/workspace-app/src/protocolViewFilter.js b/workspace-app/src/protocolViewFilter.js new file mode 100644 index 0000000..662c4f0 --- /dev/null +++ b/workspace-app/src/protocolViewFilter.js @@ -0,0 +1,22 @@ +const prefix='annotations/'; +export function readTagRules(filters={}){ + if(filters.tag_predicate){ + const ast=JSON.parse(filters.tag_predicate),mode=ast.any?'any':'all',nodes=ast[mode]; + if(!Array.isArray(nodes))throw new Error('Unsupported saved tag filter'); + return {mode,rules:nodes.map(node=>{const condition=node.not||node;return {scope:condition.field.slice(prefix.length).split('/')[0],comparison:node.not?'is_not':'is',value:condition.value};})}; + } + return {mode:'all',rules:[{scope:'effective',comparison:'is',value:filters.tag||''}]}; +} +export function compileTagRules(mode,rules){ + if(!['all','any'].includes(mode)||!rules.length||rules.length>20)throw new Error('Choose 1–20 tag rules.'); + return {[mode]:rules.map(rule=>{ + if(!['effective','cell','epoch'].includes(rule.scope)||!['is','is_not'].includes(rule.comparison)||typeof rule.value!=='string'||!rule.value.trim())throw new Error('Choose a scope, comparison and tag for each rule.'); + const node={field:`annotations/${rule.scope}/tags`,operator:'contains',value:rule.value.trim()}; + return rule.comparison==='is_not'?{not:node}:node; + })}; +} +export function clearTagFilters(filters){const next={...filters};delete next.tag;delete next.tagged;delete next.tag_predicate;return next;} +export function tagFilterLabel(filters={}){ + if(filters.tag_predicate){try{const {mode,rules}=readTagRules(filters);return `${mode==='any'?'Any':'All'} of ${rules.length} tag ${rules.length===1?'rule':'rules'}`;}catch{return 'Tag filter';}} + return filters.tag?`Tag: ${filters.tag}`:filters.tagged?'Tagged epochs':''; +} diff --git a/workspace-app/src/protocolViewFilter.test.js b/workspace-app/src/protocolViewFilter.test.js new file mode 100644 index 0000000..c4e3801 --- /dev/null +++ b/workspace-app/src/protocolViewFilter.test.js @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {compileTagRules,readTagRules,clearTagFilters,tagFilterLabel} from './protocolViewFilter.js'; +test('multiple tag rules preserve all/any, scopes, and negative membership on reopen',()=>{ + const rules=[{scope:'effective',comparison:'is',value:' selected '},{scope:'cell',comparison:'is_not',value:'artifact'}]; + for(const mode of ['all','any']){ + const predicate=compileTagRules(mode,rules); + assert.deepEqual(predicate[mode][1],{not:{field:'annotations/cell/tags',operator:'contains',value:'artifact'}}); + assert.deepEqual(readTagRules({tag_predicate:JSON.stringify(predicate)}),{mode,rules:[{...rules[0],value:'selected'},rules[1]]}); + } +}); +test('clearing tags preserves other protocol filters and shows readable multi-rule scope',()=>{ + const filters={cell_type:'RGC',tag:'old',tagged:'true',tag_predicate:JSON.stringify(compileTagRules('all',[{scope:'epoch',comparison:'is',value:'keep'}]))}; + assert.deepEqual(clearTagFilters(filters),{cell_type:'RGC'}); + assert.equal(tagFilterLabel(filters),'All of 1 tag rule'); + assert.ok(filters.tag_predicate); + assert.throws(()=>compileTagRules('all',[{scope:'effective',comparison:'is',value:''}])); +}); From 73ef4259feb32df2eeba80ac90f0c00d33527a65 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:03:34 -0700 Subject: [PATCH 6/9] Move collapse controls into the tree header and remove footer clutter --- workspace-app/src/App.jsx | 2 +- workspace-app/src/components/EpochBrowserChrome.jsx | 4 ++-- workspace-app/src/components/HierarchyTree.jsx | 4 +++- workspace-app/src/components/InspectionCellTree.jsx | 8 +++++--- workspace-app/src/components/Inspector.css | 5 +++++ workspace-app/src/components/Inspector.jsx | 9 +++++---- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx index 151c8cd..2364e77 100644 --- a/workspace-app/src/App.jsx +++ b/workspace-app/src/App.jsx @@ -137,4 +137,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}/>:}
    Source files preserved · stable epoch identitiesMetadata first. Traces loaded on demand.
    ;} + const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return
    {setup}
    {folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>}
    ;return
    / {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/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx index ad587d0..5a7c820 100644 --- a/workspace-app/src/components/EpochBrowserChrome.jsx +++ b/workspace-app/src/components/EpochBrowserChrome.jsx @@ -11,6 +11,6 @@ export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ export function EpochNavigation({position,total,loading=false,disabled=false,onMove}){ return
    {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}){ - return
    {onTreeMode&&<>}
    {onDesign&&}
    ; +export function EpochListHeading({treeMode=false,onTreeMode,onDesign,designDisabled=false,onCollapse}){ + return
    {onCollapse&&}
    {onTreeMode&&<>}
    {onDesign&&}
    ; } diff --git a/workspace-app/src/components/HierarchyTree.jsx b/workspace-app/src/components/HierarchyTree.jsx index b96b324..58b4489 100644 --- a/workspace-app/src/components/HierarchyTree.jsx +++ b/workspace-app/src/components/HierarchyTree.jsx @@ -96,6 +96,8 @@ export default function HierarchyTree(props){ commit(old=>({...old,expanded:[],loading:false,loadingPath:null})); callbacks.current.onStatus?.({loading:false,error:null}); } + const previousCollapseRequest=useRef(props.collapseRequest); + useEffect(()=>{if(previousCollapseRequest.current!==props.collapseRequest){previousCollapseRequest.current=props.collapseRequest;collapseAll();}},[props.collapseRequest]); function branchKeys(event,branch,opened){ if(event.key==='ArrowRight'){event.preventDefault();event.stopPropagation();if(!opened)toggle(branch);else event.currentTarget.closest('li')?.querySelector('ul button')?.focus({preventScroll:true});} if(event.key==='ArrowLeft'){event.preventDefault();event.stopPropagation();if(opened)toggle(branch);else event.currentTarget.closest('ul')?.closest('li')?.querySelector(':scope > button')?.focus({preventScroll:true});} @@ -120,6 +122,6 @@ export default function HierarchyTree(props){ {state.error&&
    {state.error}
    } {state.notice&&
    {state.notice}
    }
    {if(!scrollFrame.current)scrollFrame.current=requestAnimationFrame(()=>{scrollFrame.current=null;remember();});}}>{root?renderPage(root,true):!state.error&&

    Loading tree…

    }
    -
    Expand groups to inspect epochs. Grouping preserves the selection.
    + {!props.externalCollapseControl&&
    } ; } diff --git a/workspace-app/src/components/InspectionCellTree.jsx b/workspace-app/src/components/InspectionCellTree.jsx index 64fd151..28d737c 100644 --- a/workspace-app/src/components/InspectionCellTree.jsx +++ b/workspace-app/src/components/InspectionCellTree.jsx @@ -26,18 +26,20 @@ function CellEpochs({cell,source,revision,focused,onFocus,targets,onSelect,disab {page.data&&
    {page.data.total?offset+1:0}–{Math.min(offset+60,page.data.total)} of {number(page.data.total)}
    } ; } -function CellBranch({cell,dateOpen,onSelectCell,selectedCell,...props}){ +function CellBranch({cell,dateOpen,onSelectCell,selectedCell,collapseRequest,...props}){ const [open,setOpen]=useState(false); + useEffect(()=>setOpen(false),[collapseRequest]); return
    setOpen(event.currentTarget.open)}> onSelectCell(cell)}>{cell.label||cell.cell_label||'Unlabeled cell'} {dateOpen&&open&&}
    ; } -function DateBranch({group,...props}){ +function DateBranch({group,collapseRequest,...props}){ const [open,setOpen]=useState(false); + useEffect(()=>setOpen(false),[collapseRequest]); return
    setOpen(event.currentTarget.open)}> {group.date} - {group.cells.map(cell=>)} + {group.cells.map(cell=>)}
    ; } export default function InspectionCellTree({cells,targets,setTargets,disabled,onFocus,onSelectCell,...props}){ diff --git a/workspace-app/src/components/Inspector.css b/workspace-app/src/components/Inspector.css index 3be4455..4c4f4c8 100644 --- a/workspace-app/src/components/Inspector.css +++ b/workspace-app/src/components/Inspector.css @@ -29,3 +29,8 @@ .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} diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 8fd076c..3c18b22 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -132,6 +132,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi const [designMode,setDesignMode]=useState(initialNavigation?.designMode || false),[designPath,setDesignPath]=useState(initialNavigation?.designPath || []); const [designNavigation,setDesignNavigation]=useState(initialNavigation?.designNavigation||null); const changeSplits=useCallback(fields=>{setSplits(fields.join(','));setDesignPath([]);setDesignNavigation(null);},[]); + const [collapseRequest,setCollapseRequest]=useState(0); const [treeOpen,setTreeOpen]=useState(initialNavigation?.treeOpen ?? true),[treeMode,setTreeMode]=useState(initialNavigation?.treeMode ?? false); const {protocol:protocolSearch,navigation:search}=inspectionSearches(filters,focusCell); const rowsPath=`/protocols/${id}/epochs?${search}&${pendingNavigation?.anchorUuid?`anchor_uuid=${encodeURIComponent(pendingNavigation.anchorUuid)}`:`offset=${offset}`}&limit=60`; @@ -310,18 +311,18 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi
    {treeOpen&&} {treeOpen&&changePane('tree',value)} onCommit={value=>savePane('tree',value)}/>} - {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?<> + {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?<>

    {datedCellLabel(focusedEpoch)}

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

    From 792e2e49c350410f60b3eb2aa88201162d516fc9 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:02 -0700 Subject: [PATCH 7/9] Separate epoch pane controls from rows with a divider --- workspace-app/src/components/Inspector.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workspace-app/src/components/Inspector.css b/workspace-app/src/components/Inspector.css index 4c4f4c8..f958787 100644 --- a/workspace-app/src/components/Inspector.css +++ b/workspace-app/src/components/Inspector.css @@ -34,3 +34,5 @@ .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} From 4c423dc7e885dff0d49cdd7936bed7d20056f59f Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:08:15 -0700 Subject: [PATCH 8/9] Combine protocol navigation and inspection controls in one header --- workspace-app/src/App.jsx | 5 +++-- workspace-app/src/WorkspaceNavigation.css | 8 ++++++++ workspace-app/src/components/EpochBrowserChrome.jsx | 6 ++++-- workspace-app/src/components/Inspector.jsx | 4 ++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx index 2364e77..ae1223e 100644 --- a/workspace-app/src/App.jsx +++ b/workspace-app/src/App.jsx @@ -54,6 +54,7 @@ function Protocol({id,revision,onChange,onTagFilter,initialRecipe,initialInspect const inspectorSession=useRef(saved.inspector || null); const [initialEpoch,setInitialEpoch]=useState(saved.initialEpoch || null); const [browseRequest,setBrowseRequest]=useState(0); + const [inspectorToolbarTarget,setInspectorToolbarTarget]=useState(null); function browseEpochs(){inspectorSession.current={...inspectorSession.current,designMode:false,treeMode:false,treeOpen:true};setBrowseRequest(value=>value+1);setTab('inspect');} const treeLayout=useProtocolTreeLayout(id,initialRecipe?.split_order); const splitOrder=treeLayout.order,setSplitOrder=treeLayout.remember; @@ -83,8 +84,8 @@ function Protocol({id,revision,onChange,onTagFilter,initialRecipe,initialInspect
    - {treeLayout.save.status==='saving'?'Saving tree…':treeLayout.save.status==='error'?'Tree not saved':treeLayout.save.version>0?'Tree saved':''}{Object.keys(filters).length?`${Object.keys(filters).length} active filters`:'All matching cells'} -
    {treeLayout.save.status==='error'&&
    Tree layout could not be saved: {treeLayout.save.error}
    }{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel}}{tab==='inspect'?{setFilters(next);setResult(null);setError('');}} onTagFilter={onTagFilter} key={browseRequest} initialEpochUuid={initialEpoch} initialNavigation={inspectorSession.current} onSessionChange={rememberInspector} onQC={onQC} protocol={data} splitRecipe={splitOrder} onSplitChange={setSplitOrder} cellScope={scope} filters={filters} revision={revision} onChange={onChange} onBack={()=>setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:
    PROTOCOL WORKSPACE

    {humanize(data.definition?.name)}

    {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}

    {initialRecipe&&
    Reused saved query; matching the current catalog. Previous export unchanged.
    }{diff&&
    {diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)}<>{diff.candidate_revision_uuid&&}
    }{error&&tab!=='export'&&
    {error}
    }Loading dataset summary…
    }>
    Filter results{Object.keys(filters).length>0&&}Group labels are original metadata, not inferred drug phases.
    <>{suggestionsError&&
    New-data suggestions could not load.
    }{suggestion&&}

    Cells in this protocol

    }}
    ; + {treeLayout.save.status==='saving'?'Saving tree…':treeLayout.save.status==='error'?'Tree not saved':treeLayout.save.version>0?'Tree saved':''}
    +
    {treeLayout.save.status==='error'&&
    Tree layout could not be saved: {treeLayout.save.error}
    }{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel}}{tab==='inspect'?{setFilters(next);setResult(null);setError('');}} onTagFilter={onTagFilter} key={browseRequest} initialEpochUuid={initialEpoch} initialNavigation={inspectorSession.current} onSessionChange={rememberInspector} onQC={onQC} protocol={data} splitRecipe={splitOrder} onSplitChange={setSplitOrder} cellScope={scope} filters={filters} revision={revision} onChange={onChange} onBack={()=>setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:
    PROTOCOL WORKSPACE

    {humanize(data.definition?.name)}

    {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}

    {initialRecipe&&
    Reused saved query; matching the current catalog. Previous export unchanged.
    }{diff&&
    {diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)}<>{diff.candidate_revision_uuid&&}
    }{error&&tab!=='export'&&
    {error}
    }Loading dataset summary…
    }>
    Filter results{Object.keys(filters).length>0&&}Group labels are original metadata, not inferred drug phases.
    <>{suggestionsError&&
    New-data suggestions could not load.
    }{suggestion&&}

    Cells in this protocol

    }}
    ; } function ImportPage({projectId,protocols,sources,onChange,onBack,jobs,suggestions=[],approvedHistory=[],suggestionsError,onRetrySuggestions,onReviewCandidate,onProtocol,queue}) { const jobRecords=Array.isArray(jobs.data?.jobs)?jobs.data.jobs:[]; diff --git a/workspace-app/src/WorkspaceNavigation.css b/workspace-app/src/WorkspaceNavigation.css index 0e8bfea..7dfc009 100644 --- a/workspace-app/src/WorkspaceNavigation.css +++ b/workspace-app/src/WorkspaceNavigation.css @@ -22,3 +22,11 @@ .header-predicate-button{flex-shrink:0;white-space:nowrap;border-color:#b8a1ca;color:var(--accent);background:#f6f0fa;font-weight:600} .protocol-view-tabs>button{display:flex;align-items:center;gap:6px} .app-header .breadcrumb{flex:0 1 auto}.app-header .header-predicate-button{margin-right:8px} + +/* Keep protocol navigation and inspection actions in one compact header. */ +.workspace-context-toolbar{flex-wrap:wrap;column-gap:10px;row-gap:6px} +.protocol-inspector-controls{margin-left:auto;min-width:0} +.protocol-inspector-controls:empty{display:none} +.protocol-inspector-controls>.compact-inspector-toolbar{display:flex;align-items:center;flex-wrap:wrap;justify-content:flex-end;gap:7px;padding:0;border:0;min-height:0;position:relative;z-index:40} +.protocol-inspector-controls .spacer{display:none} +.protocol-inspector-controls .inspection-filter-summary{font-size:11px} diff --git a/workspace-app/src/components/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx index 5a7c820..b72de1f 100644 --- a/workspace-app/src/components/EpochBrowserChrome.jsx +++ b/workspace-app/src/components/EpochBrowserChrome.jsx @@ -1,9 +1,11 @@ +import {createPortal} from 'react-dom'; import {Activity,GitBranch,MessageCircle,PanelRightClose,PanelRightOpen,ArrowLeft,ArrowRight,X,Download} from 'lucide-react'; 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}){ - return
    {(!treeControlsInPane||designMode)&&}{!designMode&&<>{filterControl}}{children}{onExport&&}
    ; +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&&}
    ; + return portalTarget?createPortal(toolbar,portalTarget):toolbar; } export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){ return targets.length>0&&
    {number(targets.length)} epochs selected
    ; diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 3c18b22..674ed9d 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -108,7 +108,7 @@ function ScientificContext({epoch}) { ; } -function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revision,onChange,onBack,onImport,onStores,onExport,splitRecipe=['date','cell','block'],onSplitChange,initialNavigation=null,onSessionChange,onQC,onTagFilter,onFilterChange}) { +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); const [focusCell,setFocusCell]=useState(initialNavigation&&Object.hasOwn(initialNavigation,'focusCell')?initialNavigation.focusCell:requestedCellFocus); @@ -280,7 +280,7 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi 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={[ + } designMode={designMode} onBrowse={()=>{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}, From f2bbb1098aa030c086dc7d8b7f64d39b3f691ac1 Mon Sep 17 00:00:00 2001 From: arthurh <49833088+maxwellsdm1867@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:16:54 -0700 Subject: [PATCH 9/9] Share epoch viewer layouts and exact single-epoch exports across sources --- python/tests/test_workspace_api.py | 21 +++++++++ .../tests/test_workspace_candidate_exports.py | 14 ++++++ .../test_workspace_catalog_optimization.py | 6 ++- python/tests/test_workspace_disk_index.py | 4 +- python/workspace_api.py | 6 ++- python/workspace_disk_index.py | 4 +- python/workspace_service.py | 6 ++- python/workspace_tree.py | 3 +- workspace-app/src/WorkspaceNavigation.css | 8 ++-- .../src/components/EpochBrowserLayout.jsx | 13 ++++++ .../src/components/EpochDetailHeading.jsx | 8 ++++ .../src/components/EpochExportButton.jsx | 35 +++++++++++++++ .../src/components/EpochTreePane.jsx | 16 +++++++ workspace-app/src/components/Inspector.jsx | 45 +++++++++---------- .../src/components/MatchingEpochs.jsx | 38 +++++++++------- .../src/components/MetadataExplorer.css | 2 + .../src/components/MetadataExplorer.jsx | 20 +++++---- workspace-app/src/epochExport.js | 11 +++++ workspace-app/src/epochExport.test.js | 17 +++++++ 19 files changed, 216 insertions(+), 61 deletions(-) create mode 100644 workspace-app/src/components/EpochBrowserLayout.jsx create mode 100644 workspace-app/src/components/EpochDetailHeading.jsx create mode 100644 workspace-app/src/components/EpochExportButton.jsx create mode 100644 workspace-app/src/components/EpochTreePane.jsx create mode 100644 workspace-app/src/epochExport.js create mode 100644 workspace-app/src/epochExport.test.js diff --git a/python/tests/test_workspace_api.py b/python/tests/test_workspace_api.py index 93b5a48..d067368 100644 --- a/python/tests/test_workspace_api.py +++ b/python/tests/test_workspace_api.py @@ -418,6 +418,27 @@ def test_export_saves_verified_dynamic_tree_order(self): self.assertEqual(saved['recipe']['options']['split_order'], 'cell, parameters/frequencyCutoff, block') self.assertEqual({row['uuid'] for row in saved['recipe']['epochs']}, set(self.service.ids)) + def test_focused_epoch_export_freezes_exact_uuid_with_repeated_numbers(self): + key = self.service.ids[1] # Both fixture rows are Epoch 1. + response = self.client.post(self.base + '/exports', json={ + 'query_revision': self.revision(), 'filters': {'epoch_uuid': key}, + 'review_policy': 'include_unreviewed'}, headers=self.headers) + 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']], [key]) + self.assertEqual(response.get_json()['epoch_count'], 1) + + def test_focused_export_rejects_excluded_and_out_of_view_without_artifacts(self): + initial_exports = set((Path(self.temp.name) / 'exports').rglob('*')) + key = self.service.ids[0] + self.client.post(self.base + '/curation', json=self.curation_body({'included': False}, [key]), headers=self.headers) + for filters in ({'epoch_uuid': key}, {'epoch_uuid': self.service.ids[1], 'cell_uuid': self.service.cell_ids[0]}): + response = self.client.post(self.base + '/exports', json={ + 'query_revision': self.revision(), 'filters': filters}, headers=self.headers) + self.assertEqual(response.status_code, 400, response.get_json()) + self.assertFalse(self.datasets.rows) + self.assertEqual(set((Path(self.temp.name) / 'exports').rglob('*')), initial_exports) + def test_selection_export_defaults_to_optional_review_and_links_exact_members(self): counts = self.client.get(self.base).get_json()['counts'] self.assertEqual((counts['exportable'], counts['approved_exportable']), (2, 0)) diff --git a/python/tests/test_workspace_candidate_exports.py b/python/tests/test_workspace_candidate_exports.py index 0d24d0f..31ced0c 100644 --- a/python/tests/test_workspace_candidate_exports.py +++ b/python/tests/test_workspace_candidate_exports.py @@ -56,6 +56,20 @@ 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_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': [ + {'field': 'protocol', 'operator': 'eq', 'value': 'example'}, + {'field': 'epoch', 'operator': 'eq', 'value': key}]}) + 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']], [key]) + self.assertEqual(response.get_json()['epoch_count'], 1) + self.assertFalse(self.case.curation.rows) + self.assertFalse(self.case.protocol_bindings.rows) + def test_sqlite_exact_candidate_ignores_unrelated_protocol_masks_and_keeps_history(self): key=self.service.ids[0] self.store.update(self.service.protocol_id,[key],{'included':False,'tags_add':['protocol-only']}, diff --git a/python/tests/test_workspace_catalog_optimization.py b/python/tests/test_workspace_catalog_optimization.py index cb19ea4..cc5cf82 100644 --- a/python/tests/test_workspace_catalog_optimization.py +++ b/python/tests/test_workspace_catalog_optimization.py @@ -57,7 +57,11 @@ def legacy_predicate_catalog(catalog, values): class CatalogOptimizationTests(unittest.TestCase): def test_catalog_and_values_match_preoptimization_fixture_exactly(self): catalog, values = tree.catalog(*fixture()) - digest = hashlib.sha256(json.dumps([catalog, values], sort_keys=True, allow_nan=False).encode()).hexdigest() + # Epoch UUID is a new identity field; existing scientific fields retain the historical oracle. + self.assertEqual({key: current['epoch'] for key, current in values.items()}, {key: key for key in values}) + legacy_catalog = {**catalog, 'fields': [field for field in catalog['fields'] if field['id'] != 'epoch']} + legacy_values = {key: {field: value for field, value in current.items() if field != 'epoch'} for key, current in values.items()} + digest = hashlib.sha256(json.dumps([legacy_catalog, legacy_values], sort_keys=True, allow_nan=False).encode()).hexdigest() # Captured from the unoptimized implementation before this change. self.assertEqual(digest, '97d7c1e6971e4a563f4caaafb4f0d3d241404a45a23a2279847bc8fe17377df1') fields = {item['id']: item for item in catalog['fields']} diff --git a/python/tests/test_workspace_disk_index.py b/python/tests/test_workspace_disk_index.py index fa85ef4..b13f36e 100644 --- a/python/tests/test_workspace_disk_index.py +++ b/python/tests/test_workspace_disk_index.py @@ -47,6 +47,8 @@ def test_predicate_types_missing_and_scoped_not(self): catalog,values=tree.catalog(self.rows,self.details,sources=self.sources) self.assertEqual(index.predicate_catalog(),predicates.predicate_catalog(catalog,values)) tests=[{'field':'parameters/a~1b~0','operator':op,**({'value':value} if op not in predicates.UNARY else {})} for op,value in [('eq',1),('eq',True),('eq',[0,675]),('ne',1),('contains',1),('exists',None),('missing',None),('is_null',None)]] + tests += [{'field':'epoch','operator':'eq','value':'epoch-1'}] + self.assertEqual(index.match({'field':'epoch','operator':'eq','value':'epoch-1'})[1], ['epoch-1']) tests += [{'not':{'field':'parameters/sometimes','operator':'eq','value':2}}, {'all':[]},{'any':[]},{'all':[{'field':'parameters/frequencyCutoff','operator':'gte','value':100},{'any':[{'field':'parameters/sometimes','operator':'missing'},{'field':'parameters/sometimes','operator':'is_null'}]}]}] for predicate in tests: expected=predicates.evaluate(predicate,catalog,values) @@ -96,7 +98,7 @@ def test_empty_and_persisted_summaries(self): self.assertIsNotNone(opened._catalog_cache) self.assertIsNotNone(opened._predicate_cache) mutated=opened.catalog();mutated['fields'].clear() - self.assertEqual(len(opened.catalog()['fields']),8) + self.assertEqual(len(opened.catalog()['fields']),len(tree.BASE_FIELDS)) def test_truncated_choices_and_numeric_identity_exact(self): rows,details,sources=fixture(160) sequence=[1,1.0,True,-0.0,0.0,0,None,2**60,2**60+1,float(2**60)]+list(range(100))+[1.0,49,99]*16 diff --git a/python/workspace_api.py b/python/workspace_api.py index deebe32..8461a1a 100644 --- a/python/workspace_api.py +++ b/python/workspace_api.py @@ -131,9 +131,9 @@ def create_app(project_dir, retinanalysis_dir, *, service=None, store=None, expl def filters(allowed=()): if any(len(request.args.getlist(key)) != 1 for key in ('tag', 'tagged', 'tag_predicate') if key in request.args): raise ValueError('Tag filters must be specified once') - if set(request.args) - {"cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate", "offset", "limit", "splits"} - set(allowed): + if set(request.args) - {"epoch_uuid", "cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate", "offset", "limit", "splits"} - set(allowed): raise ValueError("Unknown query filter; no unrestricted fallback was applied") - return {key: request.args[key] for key in ("cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate") + return {key: request.args[key] for key in ("epoch_uuid", "cell_uuid", "cell_type", "group_label", "tag", "tagged", "tag_predicate") if key in request.args} def state(protocol_uuid): @@ -1047,6 +1047,8 @@ def export(protocol_uuid): field_catalog = {field["id"]: field for field in service.tree_fields(protocol_uuid, splits=split_order)["fields"]} rows = service.filtered_rows(protocol_uuid, query_filters) included = [r["epoch_uuid"] for r in rows if curation[r["epoch_uuid"]]["included"]] + if 'epoch_uuid' in query_filters and included != [str(uuid.UUID(query_filters['epoch_uuid']))]: + raise ValueError('The focused epoch is excluded or no longer matches this protocol view') approved = [key for key, value in curation.items() if value["review_state"] == "approved"] name = str(body.get("name") or definition["name"]).strip()[:120] recipe = prepare_export(snapshot, included, destination=export_format, diff --git a/python/workspace_disk_index.py b/python/workspace_disk_index.py index 64f932e..98f57c2 100644 --- a/python/workspace_disk_index.py +++ b/python/workspace_disk_index.py @@ -22,7 +22,7 @@ import workspace_tree as tree import workspace_predicates as predicates -FORMAT = 1 +FORMAT = 2 # Epoch identity is now an indexed predicate field. CHUNK_SIZE = 256 CACHE_SIZE = 64 @@ -432,7 +432,7 @@ def _suggest(fields,index,ids,family): for field in fields: field.update(suggested_rank=None,suggestion_reason='') count=field['recorded_distinct_count'];high=count>24 or count>max(5,len(ids)*.25);field['high_cardinality']=high - if not field['varying'] or high or field.get('grouping_role')=='technical' or field['id'] in {'block','block time','group'}: continue + if not field['varying'] or high or field.get('grouping_role')=='technical' or field['id'] in {'epoch','block','block time','group'}: continue if field['id'].rsplit('/',1)[-1].lower() in {'seed','uuid','id'}: continue candidates.append(field) candidates.sort(key=lambda f:(priority.get(f['id'],20 if f['category']=='Parameters' else 30),not f.get('varies_within_cell',False),f['missing_count'],f['recorded_distinct_count'],f['id'])) diff --git a/python/workspace_service.py b/python/workspace_service.py index c34e7ea..ba4b8ac 100644 --- a/python/workspace_service.py +++ b/python/workspace_service.py @@ -52,8 +52,8 @@ def _fingerprint(epoch): def validate_filters(filters): filters = {} if filters is None else filters - if not isinstance(filters, dict) or set(filters) - {'cell_uuid', 'cell_type', 'group_label', 'tag', 'tagged', 'tag_predicate'}: - raise ValueError('Unsupported filter; use cell_uuid, cell_type, group_label, tag, tagged, or tag_predicate') + if not isinstance(filters, dict) or set(filters) - {'epoch_uuid', 'cell_uuid', 'cell_type', 'group_label', 'tag', 'tagged', 'tag_predicate'}: + raise ValueError('Unsupported filter; use epoch_uuid, cell_uuid, cell_type, group_label, tag, tagged, or tag_predicate') if any(not isinstance(value, str) for value in filters.values()): raise ValueError('Filter values must be text') if 'tag' in filters: @@ -65,6 +65,8 @@ def validate_filters(filters): predicate = validate_tag_filter_predicate(filters['tag_predicate']) filters = {**filters, 'tag_predicate': json.dumps(predicate, sort_keys=True, separators=(',', ':'), allow_nan=False)} cleaned = {k: v for k, v in filters.items() if v} + if 'epoch_uuid' in cleaned: + cleaned['epoch_uuid'] = _uuid(cleaned['epoch_uuid']) if 'cell_uuid' in cleaned: cleaned['cell_uuid'] = _uuid(cleaned['cell_uuid']) return cleaned diff --git a/python/workspace_tree.py b/python/workspace_tree.py index 93f23b2..1625931 100644 --- a/python/workspace_tree.py +++ b/python/workspace_tree.py @@ -41,6 +41,7 @@ def values(self): return (self._row(value) for value in self.base.values()) BASE_FIELDS = { + 'epoch': ('Epoch UUID', 'Recording', 'epoch_uuid'), 'date': ('Recording date', 'Recording', 'date'), 'cell': ('Cell', 'Recording', 'cell_uuid'), 'block': ('Epoch block', 'Recording', 'block_uuid'), @@ -357,7 +358,7 @@ def suggest_fields(fields, values, family=None): # searchable but do not crowd the beginner's starting suggestions. high_cardinality = count > 24 or count > max(5, len(values) * .25) field['high_cardinality'] = high_cardinality - if not field['varying'] or high_cardinality or field.get('grouping_role') == 'technical' or field['id'] in {'block', 'block time', 'group'}: + if not field['varying'] or high_cardinality or field.get('grouping_role') == 'technical' or field['id'] in {'epoch', 'block', 'block time', 'group'}: continue if field['id'].rsplit('/', 1)[-1].lower() in {'seed', 'uuid', 'id'}: continue diff --git a/workspace-app/src/WorkspaceNavigation.css b/workspace-app/src/WorkspaceNavigation.css index 7dfc009..f8d1f26 100644 --- a/workspace-app/src/WorkspaceNavigation.css +++ b/workspace-app/src/WorkspaceNavigation.css @@ -25,8 +25,8 @@ /* Keep protocol navigation and inspection actions in one compact header. */ .workspace-context-toolbar{flex-wrap:wrap;column-gap:10px;row-gap:6px} -.protocol-inspector-controls{margin-left:auto;min-width:0} -.protocol-inspector-controls:empty{display:none} -.protocol-inspector-controls>.compact-inspector-toolbar{display:flex;align-items:center;flex-wrap:wrap;justify-content:flex-end;gap:7px;padding:0;border:0;min-height:0;position:relative;z-index:40} -.protocol-inspector-controls .spacer{display:none} +.protocol-inspector-controls,.epoch-browser-header-controls{margin-left:auto;min-width:0} +.protocol-inspector-controls:empty,.epoch-browser-header-controls:empty{display:none} +.protocol-inspector-controls>.compact-inspector-toolbar,.epoch-browser-header-controls>.compact-inspector-toolbar{display:flex;align-items:center;flex-wrap:wrap;justify-content:flex-end;gap:7px;padding:0;border:0;min-height:0;position:relative;z-index:40} +.protocol-inspector-controls .spacer,.epoch-browser-header-controls .spacer{display:none} .protocol-inspector-controls .inspection-filter-summary{font-size:11px} diff --git a/workspace-app/src/components/EpochBrowserLayout.jsx b/workspace-app/src/components/EpochBrowserLayout.jsx new file mode 100644 index 0000000..0bc0a61 --- /dev/null +++ b/workspace-app/src/components/EpochBrowserLayout.jsx @@ -0,0 +1,13 @@ +import {NavigationLoadingNotice} from './NavigationLoading.jsx'; +import PaneDivider from './PaneDivider.jsx'; + +// Source adapters supply content and state; geometry and pane chrome live here. +export default function EpochBrowserLayout({layoutRef,sizes,treeOpen,metadataOpen,editing=false,tree,detail,metadata,onResize,onResizeCommit,treeMin=180,className=''}){ + const showMetadata=metadataOpen&&!editing; + return
    + + {treeOpen&&<>onResize('tree',value)} onCommit={onResizeCommit?value=>onResizeCommit('tree',value):undefined}/>} + {editing?detail:
    {detail}
    } + {showMetadata&&<>onResize('metadata',value)} onCommit={onResizeCommit?value=>onResizeCommit('metadata',value):undefined}/>{metadata}} +
    ; +} diff --git a/workspace-app/src/components/EpochDetailHeading.jsx b/workspace-app/src/components/EpochDetailHeading.jsx new file mode 100644 index 0000000..5aba9db --- /dev/null +++ b/workspace-app/src/components/EpochDetailHeading.jsx @@ -0,0 +1,8 @@ +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'}
    ; +} diff --git a/workspace-app/src/components/EpochExportButton.jsx b/workspace-app/src/components/EpochExportButton.jsx new file mode 100644 index 0000000..7228925 --- /dev/null +++ b/workspace-app/src/components/EpochExportButton.jsx @@ -0,0 +1,35 @@ +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/EpochTreePane.jsx b/workspace-app/src/components/EpochTreePane.jsx new file mode 100644 index 0000000..0df7f58 --- /dev/null +++ b/workspace-app/src/components/EpochTreePane.jsx @@ -0,0 +1,16 @@ +import {EpochListHeading} from './EpochBrowserChrome.jsx'; +import InspectionCellTree from './InspectionCellTree.jsx'; +import PagedTree from './PagedTree.jsx'; +import StableContent from './StableContent.jsx'; + +// The saved protocol and predicate browsers share the same navigation surface. +// Their data sources and inclusion policies remain explicit at the call site. +export default function EpochTreePane({treeMode,onTreeMode,onDesign,designDisabled=false,collapseRequest,onCollapse,treeProps,listProps,listRef,listKey,listStatus,children,childrenInTree=false}){ + const list=; + return <> + + {treeMode?: +
    {listStatus?{list}:list}
    } + {(!treeMode||childrenInTree)&&children} + ; +} diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx index 674ed9d..ecd1069 100644 --- a/workspace-app/src/components/Inspector.jsx +++ b/workspace-app/src/components/Inspector.jsx @@ -1,7 +1,8 @@ +import EpochDetailHeading from './EpochDetailHeading.jsx'; import {clearTagFilters,tagFilterLabel} from '../protocolViewFilter.js'; import ProtocolViewFilter from './ProtocolViewFilter.jsx'; import SelectionMaskDialog from './SelectionMaskDialog.jsx'; -import {NavigationLoadingProvider,NavigationLoadingNotice} from './NavigationLoading.jsx'; +import {NavigationLoadingProvider} from './NavigationLoading.jsx'; import StableContent from './StableContent.jsx'; import {advanceEpochIntent,epochIntentAt,epochAtIntent} from '../epochNavigationIntent.js'; import {SourceEligibilityNotice} from './Common.jsx'; @@ -15,8 +16,8 @@ import TreePreview from './TreePreview.jsx'; import './InspectorPolish.css'; import './MatlabMaskImport.css'; import MetadataPanel from './MetadataPanel.jsx'; -import InspectionCellTree from './InspectionCellTree.jsx'; -import {EpochBrowserToolbar,EpochSelectionBar,EpochNavigation,EpochListHeading} from './EpochBrowserChrome.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'; @@ -29,7 +30,7 @@ import {datedCellLabel} from '../recordingIdentity.js'; import {restoredEpochFocus} from '../workspaceNavigation.js'; import {boundedTreePage,inspectionTreeFocus} from '../boundedTree.js'; import Trace from './TraceViewer.jsx'; -import PaneDivider from './PaneDivider.jsx'; +import EpochBrowserLayout from './EpochBrowserLayout.jsx'; import PagedTree from './PagedTree.jsx'; import {inspectorPaneSizes,epochShortcutDirection,resourceForPath} from '../inspectorInteraction.js'; export {Trace}; @@ -308,25 +309,21 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi } }{!designMode&&maskMessage&&
    {maskMessage}
    } {error&&
    {error}
    } -
    - - {treeOpen&&} - {treeOpen&&changePane('tree',value)} onCommit={value=>savePane('tree',value)}/>} - {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?<> -
    -

    {datedCellLabel(focusedEpoch)}

    -

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

    -
    {onQC&&}{focusedEpoch.curation?.included===false?'Excluded':'Included'}{focusedEpoch.exports?.length?`${focusedEpoch.exports.length} saved exports`:'No saved export'}
    + } + 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}/> @@ -347,9 +344,9 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi {targets.length?`Mark ${actionScope} reviewed`:focusedEpoch.curation?.review_state==='approved'?'Clear focused epoch review marker':'Mark focused epoch reviewed'} - :Select an epoch from the tree to inspect its response and metadata.}
    } - {!designMode&&metadataOpen&&<>changePane('metadata',value)} onCommit={value=>savePane('metadata',value)}/>setTargets([])} tags={tagEntry} epoch={focusedEpoch} catalog={metadataCatalog} onClose={()=>toggleMetadata(false)} context={focusedEpoch&&} connections={focusedEpoch&&}/>} -
    + :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&&}/>}/> +
    ; } diff --git a/workspace-app/src/components/MatchingEpochs.jsx b/workspace-app/src/components/MatchingEpochs.jsx index ad9f405..b07d5de 100644 --- a/workspace-app/src/components/MatchingEpochs.jsx +++ b/workspace-app/src/components/MatchingEpochs.jsx @@ -1,30 +1,30 @@ -import {NavigationLoadingProvider,NavigationLoadingNotice} from './NavigationLoading.jsx'; +import EpochDetailHeading from './EpochDetailHeading.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} from 'lucide-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 {datedCellLabel} from '../recordingIdentity.js'; import {Trace} from './Inspector.jsx'; -import InspectionCellTree from './InspectionCellTree.jsx'; -import {EpochBrowserToolbar,EpochSelectionBar,EpochNavigation,EpochListHeading} from './EpochBrowserChrome.jsx'; -import PagedTree from './PagedTree.jsx'; +import EpochTreePane from './EpochTreePane.jsx'; +import {EpochBrowserToolbar,EpochSelectionBar,EpochNavigation} from './EpochBrowserChrome.jsx'; import MetadataPanel from './MetadataPanel.jsx'; import EpochConnections from './EpochConnections.jsx'; -import PaneDivider from './PaneDivider.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({predicate,splits,preview,onRefresh,session,onSession,onTagFilter,onAnnotationsChanged,onDesign,designDisabled=false,onExport,exportDisabled=false,actions=[]}){ +function MatchingEpochsContent({toolbarTarget=null,onFilter,filterDisabled=false,predicate,splits,preview,onRefresh,session,onSession,onTagFilter,onAnnotationsChanged,onDesign,designDisabled=false,onExport,exportDisabled=false,actions=[]}){ 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}); const [cells,setCells]=useState(null),[targets,setTargets]=useState([]); + const [collapseRequest,setCollapseRequest]=useState(0); const [treeOpen,setTreeOpen]=useState(saved?.treeOpen??true),[treeMode,setTreeMode]=useState(saved?.treeMode??false); const [metadataOpen,setMetadataOpen]=useState(()=>{try{const value=localStorage.getItem('workspace.inspector.metadata');return value===null?window.innerWidth>=1350:value==='true';}catch{return true;}}); const [cellTagRequest,setCellTagRequest]=useState(null); @@ -63,17 +63,23 @@ function MatchingEpochsContent({predicate,splits,preview,onRefresh,session,onSes 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);}}}> - {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]}/> + 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]}/> {annotationNotice&&
    {annotationNotice}
    } {page.error&&
    {page.error}
    } -
    - - {treeOpen&&} - {treeOpen&&setSizes(old=>({...old,tree}))}/>} -
    {cellTagRequest||targets.length>0?:detail?<>

    {datedCellLabel(detail)}

    {humanize(detail.protocol_name?.split('.').at(-1))} · Epoch {detail.epoch_number} within block

    {!metadataOpen&&!treeOpen&&tagEntry}:Select an epoch from the tree to inspect its response and metadata.}
    - {metadataOpen&&<>setSizes(old=>({...old,metadata}))}/>setTargets([])} tags={tagEntry} epoch={detail} catalog={{data:preview.catalog}} onClose={()=>toggleMetadata(false)} connections={detail&&}/>} -
    + 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&&}/>}/> +
    ; } export default function MatchingEpochs(props){return ;} diff --git a/workspace-app/src/components/MetadataExplorer.css b/workspace-app/src/components/MetadataExplorer.css index f738ffc..9bc2b57 100644 --- a/workspace-app/src/components/MetadataExplorer.css +++ b/workspace-app/src/components/MetadataExplorer.css @@ -23,3 +23,5 @@ .mx-advanced-actions small{display:block;margin-top:7px;font-size:11px;color:var(--ink-secondary);line-height:1.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} diff --git a/workspace-app/src/components/MetadataExplorer.jsx b/workspace-app/src/components/MetadataExplorer.jsx index 905ab23..643a82f 100644 --- a/workspace-app/src/components/MetadataExplorer.jsx +++ b/workspace-app/src/components/MetadataExplorer.jsx @@ -17,7 +17,7 @@ import { Badge, Metadata, Status } from './Common.jsx'; import { Trace } from './Inspector.jsx'; import TreeBuilder from './TreeBuilder.jsx'; import PagedTree from './PagedTree.jsx'; -import PaneDivider from './PaneDivider.jsx'; +import EpochBrowserLayout from './EpochBrowserLayout.jsx'; import {inspectorPaneSizes} from '../inspectorInteraction.js'; import EpochConnections from './EpochConnections.jsx'; import ProtocolApplyPanel from './ProtocolApplyPanel.jsx'; @@ -33,6 +33,7 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0, const [filterSplits,setFilterSplits]=useState(saved?.filterSplits ?? 'date,protocol,cell'); const [splits,setSplits]=useState(saved?.splits ?? 'date,protocol,cell'); const [step,setStep]=useState(saved?.step || 'filter'); + const [epochToolbarTarget,setEpochToolbarTarget]=useState(null); const [editorOpen,setEditorOpen]=useState(()=>!initialRevisionId&&(!!initialPredicate||initialEditorOpen)); const presetsKey=`rieke-os.search-presets.v1.${projectId}`; const [presets,setPresets]=useState(()=>{try{const value=JSON.parse(localStorage.getItem(presetsKey)||'[]');return Array.isArray(value)?value.filter(item=>item?.predicate&&typeof item.id==='string').slice(0,100):[];}catch{return [];}}); @@ -232,16 +233,16 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,
    ):
    {step==='tree'?'Design tree':'Search results'}
    {step==='tree'&&} - + {step==='tree'&&} {step==='tree'&&<>} -
    } +
    {step==='results'&&
    }} {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)} 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}/>:} + {!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}/>:}
    :<>
    {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} @@ -249,9 +250,11 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,
    {!focused&&applied&&
    Tree layout
    } {!focused&&applied&&} -
    {try{localStorage.setItem('workspace.explorer.groupingWidth',String(value));}catch{}}}/>{!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&&<> + {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.'}

    @@ -259,7 +262,8 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,
    {['cell','group','block'].map(level=>
    {level[0].toUpperCase()+level.slice(1)} source metadata
    )} - }
    }
    + }
    }/> + }
    ; } diff --git a/workspace-app/src/epochExport.js b/workspace-app/src/epochExport.js new file mode 100644 index 0000000..8beac1d --- /dev/null +++ b/workspace-app/src/epochExport.js @@ -0,0 +1,11 @@ +// 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 new file mode 100644 index 0000000..0d7efe4 --- /dev/null +++ b/workspace-app/src/epochExport.test.js @@ -0,0 +1,17 @@ +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'}]}); +});