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/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 332475b..8461a1a 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) - {"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")
- if request.args.get(key)}
+ 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):
result = service.query_result(protocol_uuid)
@@ -1045,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 0630993..ba4b8ac 100644
--- a/python/workspace_service.py
+++ b/python/workspace_service.py
@@ -52,16 +52,42 @@ 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) - {'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:
+ 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 '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
+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 +607,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 +731,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 +765,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/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/App.jsx b/workspace-app/src/App.jsx
index 7bafbb8..ae1223e 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';
@@ -53,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;
@@ -73,7 +75,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&&} 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&&} Previous exports >);
const exportFooter=(
Logged in project activity {exportBlocked?'Update source selection first':exporting?'Saving export…':`Save & export ${number(eligible)} epochs`}
);
return
{onReturnToTree&&
Back to filtered tree }
{humanize(data?.definition?.name || 'Protocol')}
@@ -82,8 +84,8 @@ function Protocol({id,revision,onChange,onTagFilter,initialRecipe,initialInspect
Inspect epochs
setExportOpen(true)}> Export
- {treeLayout.save.status==='saving'?'Saving tree…':treeLayout.save.status==='error'?'Tree not saved':treeLayout.save.version>0?'Tree saved':''} {Object.keys(filters).length?`${Object.keys(filters).length} active filters`:'All matching cells'}
- {treeLayout.save.status==='error'&&
Tree layout could not be saved: {treeLayout.save.error}Retry tree save
}
{protocol.reload();treeLayout.reload();}}>{data&&<>{exportOpen&&setExportOpen(false)} footer={exportFooter}>{exportPanel} }{tab==='inspect'?setTab('overview')} onImport={onImport} onStores={onStores} onExport={()=>setExportOpen(true)}/>:PROTOCOL WORKSPACE
{humanize(data.definition?.name)} {data.binding?`Working dataset version ${data.binding.version}. Use Search predicate to compare and apply new source data.`:"A saved query into the project catalog. Inspection decisions stay linked to each epoch."}
Go to epochs Refresh & compare setExportOpen(true)}> Export {initialRecipe&&
Reused saved query; matching the current catalog. Previous export unchanged.
}{diff&&
{diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)} <>{diff.candidate_revision_uuid&&onReviewCandidate(diff.candidate_revision_uuid,id)}>Review update }>setDiff(null)}> }{error&&tab!=='export'&&
{error}
}
Loading dataset summary… }>Filter results onRefine(data,filters)}> Search predicate filter('cell_type',e.target.value)}>All cell types {(data.filters?.cell_types || [...new Set((data.cells||[]).map(c=>c.cell_type))]).map(x=>{x} )} filter('group_label',e.target.value)}>All source groups {(data.filters?.group_labels||data.groups||[]).map(x=>{typeof x==='string'?x:x?.label || 'Not recorded'} )} {Object.keys(filters).length>0&&{setFilters({});setResult(null);}}>Clear filters }Group labels are original metadata, not inferred drug phases.
<>{suggestionsError&&New-data suggestions could not load. Retry suggestions
}{suggestion&&}Cells in this protocol openCell(null)}>Open inspection > }>};
+ {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}Retry tree save
}
{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."}
Go to epochs Refresh & compare setExportOpen(true)}> Export {initialRecipe&&
Reused saved query; matching the current catalog. Previous export unchanged.
}{diff&&
{diff.message || (diff.baseline_created?'Saved a comparison baseline.':`Query refreshed: ${typeof diff.diff?.added==='number'?diff.diff.added:diff.diff?.added?.length||0} added · ${typeof diff.diff?.changed==='number'?diff.diff.changed:diff.diff?.changed?.length||0} changed · ${typeof diff.diff?.removed==='number'?diff.diff.removed:diff.diff?.removed?.length||0} no longer matching`)} <>{diff.candidate_revision_uuid&&onReviewCandidate(diff.candidate_revision_uuid,id)}>Review update }>setDiff(null)}> }{error&&tab!=='export'&&
{error}
}
Loading dataset summary… }>Filter results onRefine(data,filters)}> Search predicate filter('cell_type',e.target.value)}>All cell types {(data.filters?.cell_types || [...new Set((data.cells||[]).map(c=>c.cell_type))]).map(x=>{x} )} filter('group_label',e.target.value)}>All source groups {(data.filters?.group_labels||data.groups||[]).map(x=>{typeof x==='string'?x:x?.label || 'Not recorded'} )} {Object.keys(filters).length>0&&{setFilters({});setResult(null);}}>Clear filters }Group labels are original metadata, not inferred drug phases.
<>{suggestionsError&&New-data suggestions could not load. Retry suggestions
}{suggestion&&}Cells in this protocol openCell(null)}>Open inspection > }>}
;
}
function ImportPage({projectId,protocols,sources,onChange,onBack,jobs,suggestions=[],approvedHistory=[],suggestionsError,onRetrySuggestions,onReviewCandidate,onProtocol,queue}) {
const jobRecords=Array.isArray(jobs.data?.jobs)?jobs.data.jobs:[];
@@ -136,4 +138,4 @@ export default function App(){
function searchEpoch(result){if(result.protocol_uuid)openInspection(result.protocol_uuid,result);else openQC(result.cell_uuid,result.epoch_uuid);}
function searchPredicate(predicate){navigation.go('explore',{explorePredicate:predicate});}
function changed(){setRevision(x=>x+1);}
- const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return {setup}{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>} ;return setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject}/> setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject} error={projectError || registry.error} onRetry={registry.reload}/>setPage('overview')}> Project overview explore()}> Search predicate setPage('stores')}> Data stores
{data?.project?.project_uuid &&
}PROJECT RECORD
setFolderOpen(true)}> Project folder setPage('activity')}> Activity & logs setPage('files')}> Project files setPage('exports')}> Export log Main project catalog DataJoint · local source references
{hidden?:} setPage('overview')} title="Project overview">{projectName} / {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)} Search predicate openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace {reviewImportJob&&}setPage('import')}/>{folderOpen&&
setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&{setup}
}{openingProject&& Opening {openingProject}…
}{projectError&&{projectError}setProjectError('')} aria-label="Dismiss project error">
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} approvedHistory={suggestionsResource.data?.approved_history||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:} Source files preserved · stable epoch identities Metadata first. Traces loaded on demand. ;}
+ const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return {setup}{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>} ;return setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject}/> setProjectSetup(true)} projects={registeredProjects} currentProjectUuid={projectId} onSelect={selectProject} loading={registry.loading} disabled={!!openingProject} error={projectError || registry.error} onRetry={registry.reload}/>setPage('overview')}> Project overview explore()}> Search predicate setPage('stores')}> Data stores
{data?.project?.project_uuid &&
}PROJECT RECORD
setFolderOpen(true)}> Project folder setPage('activity')}> Activity & logs setPage('files')}> Project files setPage('exports')}> Export log Main project catalog DataJoint · local source references
{hidden?:} setPage('overview')} title="Project overview">{projectName} / {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)} Search predicate openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace {reviewImportJob&&}setPage('import')}/>{folderOpen&&
setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&{setup}
}{openingProject&& Opening {openingProject}…
}{projectError&&{projectError}setProjectError('')} aria-label="Dismiss project error">
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} approvedHistory={suggestionsResource.data?.approved_history||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:} ;}
diff --git a/workspace-app/src/WorkspaceNavigation.css b/workspace-app/src/WorkspaceNavigation.css
index 0e8bfea..f8d1f26 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,.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/EpochBrowserChrome.jsx b/workspace-app/src/components/EpochBrowserChrome.jsx
index a67ca50..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}){
- return Browse epochs Design tree {!designMode&&<>
Tags {metadataOpen?:} Details >}{children}{onExport&&
Export }
;
+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? Back to epochs : Edit tree } }
{!designMode&&<>{filterControl}
Tags {metadataOpen?:} Details >}{children}{onExport&&
Export }
;
+ return portalTarget?createPortal(toolbar,portalTarget):toolbar;
}
export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){
return targets.length>0&&{number(targets.length)} epochs selected 1000} onClick={onTags}> Tag selected epochs Clear
;
@@ -11,6 +13,6 @@ export function EpochSelectionBar({targets,onTags,onClear,disabled=false}){
export function EpochNavigation({position,total,loading=false,disabled=false,onMove}){
return onMove(-1)}> Previous epoch {loading?'Loading epoch…':position<0?'Focused epoch is outside the loaded page':`Epoch ${number(position+1)} of ${number(total)} matching epochs`} =0&&position+1>=total)} onClick={()=>onMove(1)}>Next epoch ;
}
-export function EpochListHeading({treeMode=false,onTreeMode}){
- return {treeMode?'Shared protocol tree':'Date · cell · epochs'} {onTreeMode&&
onTreeMode(false)}>Epochs onTreeMode(true)}>Split tree
}
;
+export function EpochListHeading({treeMode=false,onTreeMode,onDesign,designDisabled=false,onCollapse}){
+ return {onCollapse&&
Collapse all }
{onTreeMode&&<>onTreeMode(false)}>Epochs onTreeMode(true)}>Split tree >}
{onDesign&&
Edit tree }
;
}
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&&onQC(epoch.cell_uuid)} title="Open quality-control recordings linked to this cell">Cell QC }{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();}}> ,document.body);
+}
+
+export default function EpochExportButton({epoch,disabled=false,...scope}){
+ const [open,setOpen]=useState(false);
+ const excluded=!!scope.protocolId&&epoch?.curation?.included===false;
+ return <>setOpen(true)}> Export {open&&setOpen(false)}/>}>;
+}
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 onToggle(epoch,!included)}>{included?:} ;
+}
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/HierarchyTree.css b/workspace-app/src/components/HierarchyTree.css
index e20ba1d..f238ffe 100644
--- a/workspace-app/src/components/HierarchyTree.css
+++ b/workspace-app/src/components/HierarchyTree.css
@@ -5,3 +5,51 @@
.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}
+
+.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-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 3bde21f..58b4489 100644
--- a/workspace-app/src/components/HierarchyTree.jsx
+++ b/workspace-app/src/components/HierarchyTree.jsx
@@ -1,3 +1,4 @@
+import EpochInclusionToggle from './EpochInclusionToggle.jsx';
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';
@@ -7,7 +8,6 @@ import {branchLabel,branchTooltip,componentLabel,componentValue,epochLeafLabel,r
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){
@@ -78,16 +78,26 @@ 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});
+ }
+ 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});}
@@ -98,9 +108,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 {if(event.key==='ArrowLeft'){event.preventDefault();event.stopPropagation();event.currentTarget.closest('ul')?.closest('li')?.querySelector(':scope > button')?.focus({preventScroll:true});}}} onClick={event=>{remember();callbacks.current.onSelectEpoch?.(item.epoch_uuid,item,event,page,index);}}>{epochLeafLabel(item)}{datedCellLabel(item)} {item.curation?.included===false&&Excluded } ;
+ if(page.kind==='epochs')return {if(event.key==='ArrowLeft'){event.preventDefault();event.stopPropagation();event.currentTarget.closest('ul')?.closest('li')?.querySelector(':scope > button')?.focus({preventScroll:true});}}} onClick={event=>{remember();callbacks.current.onSelectEpoch?.(item.epoch_uuid,item,event,page,index);}}>{epochLeafLabel(item)}{datedCellLabel(item)} {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 {toggle(item);callbacks.current.onSelectBranch?.(item,field,page.revision);}} onKeyDown={event=>branchKeys(event,item,opened)} title={[branchTooltip(item,field?.field),item.start_time&&`Recorded start: ${item.start_time}`].filter(Boolean).join('\n')}>{loading? :opened?:}{opened?:} {label}: {item.components?.length?{item.components.map(part=>{componentLabel(part)} {componentValue(part)} )} :field?.field==='block'&&item.start_time?item.start_time:branchLabel(item,field?.field)} {field?.field==='cell'&&cell.cell_uuid===item.value)} level="cell"/>}{field?.field!=='cell'&&{number(item.count)} epochs } {opened&&(children?renderPage(children):{loading?'Loading branch…':'Branch is not loaded.'}
)} ;
+ return {if(toggle(item))callbacks.current.onSelectBranch?.(item,field,page.revision);}} onKeyDown={event=>branchKeys(event,item,opened)} title={[branchTooltip(item,field?.field),item.start_time&&`Recorded start: ${item.start_time}`].filter(Boolean).join('\n')}>{loading? :opened?:}{opened?:} {!item.components?.length&&`${label}: `} {item.components?.length?{item.components.map(part=>{componentLabel(part)} {componentValue(part)} )} :field?.field==='block'&&item.start_time?item.start_time:branchLabel(item,field?.field)} {field?.field!=='cell'&&{number(item.count)} epochs } {opened&&(children?renderPage(children):{loading?'Loading branch…':'Branch is not loaded.'}
)} ;
})}
{!entries.length&&No matching epochs. }
{(page.offset>0||page.has_more)&&load({path:page.path,offset:Math.max(0,page.offset-60)})}> {page.offset+1}–{page.offset+entries.length} / {number(page.total)} load({path:page.path,offset:page.offset+60})}> }
@@ -112,6 +122,6 @@ export default function HierarchyTree(props){
{state.error&&{state.error}expectedRevision?callbacks.current.onRefreshPreview?.():load({reset:true})}> Refresh tree
}
{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. commit(old=>({...old,expanded:[]}))}>Collapse all
+ {!props.externalCollapseControl&&}
;
}
diff --git a/workspace-app/src/components/InspectionCellTree.css b/workspace-app/src/components/InspectionCellTree.css
index ad39d0d..79c5af2 100644
--- a/workspace-app/src/components/InspectionCellTree.css
+++ b/workspace-app/src/components/InspectionCellTree.css
@@ -23,7 +23,28 @@
.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)}
+
+/* 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 98f22d8..28d737c 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 {Check,X} from 'lucide-react';
-import AnnotationIndicator from './AnnotationIndicator.jsx';
+import EpochInclusionToggle from './EpochInclusionToggle.jsx';
import {api,humanize,number} from '../api.js';
import {epochPageRequest} from '../epochBrowserSource.js';
import {epochSelectionRange,toggleEpochSelection,mergeEpochSelection} from '../epochSelection.js';
@@ -10,36 +9,37 @@ 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
- onSelectPage(page.data?.epochs||[])}>Select this page
{(page.data?.epochs||[]).map((epoch,index)=>
{if(event.shiftKey)event.preventDefault();}} onClick={event=>onSelect(event,{cellUuid:cell.cell_uuid,index:offset+index,uuid:epoch.epoch_uuid},epoch,page.data)} aria-label={`Inspect ${datedCellLabel(cell,true)} epoch ${offset+index+1}`}>
{offset+index+1}
{epoch.start_time?.split(/[T ]/)[1]?.slice(0,8)||'—'}
{humanize(epoch.protocol_name?.split('.').at(-1))||'—'}
-
- {epoch.curation?.included===false&&× }
+
+
- {onToggleInclusion&&
onToggleInclusion(epoch,epoch.curation?.included===false)}>{epoch.curation?.included===false?:} }
+ {onToggleInclusion&&
}
)}
{page.data&&setOffset(Math.max(0,offset-60))}>Previous {page.data.total?offset+1:0}–{Math.min(offset+60,page.data.total)} of {number(page.data.total)} =page.data.total} onClick={()=>setOffset(offset+60)}>Next
}
;
}
-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'}
+ 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}){
@@ -48,7 +48,7 @@ export default function InspectionCellTree({cells,targets,setTargets,disabled,on
const [selecting,setSelecting]=useState(false),[error,setError]=useState('');
const scope=JSON.stringify(props.source);
useEffect(()=>{anchor.current=null;request.current?.abort();setSelecting(false);return()=>request.current?.abort();},[scope]);
- function selectPage(rows){try{if(rows[0])onFocus(rows[0].epoch_uuid,rows[0]);setTargets(mergeEpochSelection(targets,rows.map(row=>row.epoch_uuid)));setError('');}catch(error){setError(error.message);}}
+
async function selectCell(cell){
if(disabled||selecting)return;
anchor.current=null;setError('');setSelecting(true);
@@ -84,5 +84,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 c7bed1d..f958787 100644
--- a/workspace-app/src/components/Inspector.css
+++ b/workspace-app/src/components/Inspector.css
@@ -18,3 +18,21 @@
.analysis-inclusion-toggle input{accent-color:var(--accent);width:16px;height:16px;margin:0}
.analysis-inclusion-help{font-size:12px;color:var(--ink-secondary);line-height:1.5}
.inspector .curation-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+
+/* One editing action above a compact list-view switch. */
+.epoch-inspector-mode .tree-heading{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:14px}
+.epoch-inspector-mode .tree-heading h3{margin:0;font-size:13px;line-height:1.5}
+.epoch-inspector-mode .tree-heading .segmented{display:flex;flex:0 1 auto;margin:0;width:auto;max-width:100%}
+.epoch-inspector-mode .tree-heading .segmented button{font-size:11px;padding:6px 11px;flex:0 1 auto;white-space:nowrap}
+.epoch-inspector-mode .compact-inspector-toolbar{padding:8px 14px;gap:10px}
+
+.epoch-inspector-mode .tree-heading{justify-content:center;gap:12px}
+.epoch-inspector-mode .tree-heading .segmented{margin-left:auto}
+.epoch-inspector-mode .tree-heading .tree-edit-action{margin-left:auto;white-space:nowrap;font-size:11px;padding:6px 8px}
+
+.epoch-inspector-mode .tree-heading{gap:8px;justify-content:space-between}
+.epoch-inspector-mode .tree-heading .tree-collapse-action{font-size:11px;padding:6px 8px;white-space:nowrap}
+.epoch-inspector-mode .tree-heading .segmented{margin-left:0}
+.epoch-inspector-mode .tree-heading .tree-edit-action{margin-left:0}
+
+.epoch-inspector-mode .tree-heading{border-bottom:1px solid var(--border);flex-shrink:0}
diff --git a/workspace-app/src/components/Inspector.jsx b/workspace-app/src/components/Inspector.jsx
index aaefe14..ecd1069 100644
--- a/workspace-app/src/components/Inspector.jsx
+++ b/workspace-app/src/components/Inspector.jsx
@@ -1,5 +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';
@@ -13,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';
@@ -27,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};
@@ -106,7 +109,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,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);
@@ -130,6 +133,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`;
@@ -277,12 +281,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&&{datedCellLabel(protocol.cells?.find(cell=>cell.cell_uuid===focusCell)||focusedEpoch||{},true)} }
+ {!designMode&&tagFilterLabel(filters)&&{tagFilterLabel(filters)}onFilterChange?.(clearTagFilters(filters))}>Clear filter }
{!designMode&&<>
@@ -304,26 +309,21 @@ function InspectorContent({protocol,initialEpochUuid=null,cellScope,filters,revi
}
>}{!designMode&&maskMessage&&{maskMessage} setMaskMessage('')} aria-label="Dismiss mask result">
}
{error&&{error}setError('')} aria-label="Dismiss operation error">
}
-
-
- {treeOpen&&
- {!designMode&&{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));}}/>}
- {designMode?:treeMode?<>
- {splits?`${splits.split(',').length} splits`:'Flat epoch list'} setDesignMode(true)}> Edit tree
- {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)}/>
+ preview={tree.data} loading={tree.loading} error={tree.error}/>:{setTreeMode(value);if(value)changePane('tree',Math.max(420,paneSizes.tree));}}
+ onDesign={()=>{setDesignMode(true);setTreeOpen(true);setTreeMode(true);}} designDisabled={busy}
+ collapseRequest={collapseRequest} onCollapse={()=>setCollapseRequest(value=>value+1)}
+ treeProps={{actionsDisabled:busy,onToggleInclusion:(item,included)=>curate({included},'focused',item.epoch_uuid),cells:protocol.cells,selectedEpochs:targets,setSelectedEpochs:selectOverviewTargets,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cellUuid,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setDesignMode(false);setCellTagRequest((protocol.cells||[]).find(cell=>cell.cell_uuid===cellUuid)||{cell_uuid:cellUuid,label:epoch.cell_label,date:epoch.date,cell_type:epoch.cell_type});},protocolId:id,filters,splits,revision,selected:focused,initialNavigation:designNavigation,onNavigationChange:setDesignNavigation,onSelectEpoch:focusTreeEpoch,onMetadata:receiveTree,onStatus:setTreeStatus}}
+ listRef={epochListRef} listKey={`${id}:${protocolSearch}`}
+ listProps={{cells:protocol.cells||[],source:{kind:'protocol',protocolId:id,query:protocolSearch},revision,focused:cellTagRequest?null:focused,onFocus:focusTreeEpoch,selectedCell:cellTagRequest?.cell_uuid,onSelectCell:(cell,epoch)=>{focusTreeEpoch(epoch.epoch_uuid,epoch);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);},targets,setTargets:selectOverviewTargets,disabled:busy,onToggleInclusion:(epoch,included)=>curate({included},'focused',epoch.epoch_uuid)}}>
{!metadataOpen&&{tagEntry} }
- >}
- }
- {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?<>
-
-
{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&&onQC(focusedEpoch.cell_uuid)} title="Open quality-control recordings linked to this cell">Cell QC }{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}/>
@@ -344,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}Refresh results setAnnotationNotice('')}>×
}
{page.error&&{page.error}Refresh predicate results
}
-
-
- {treeOpen&&
{treeMode?{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});}} presentation="tree" predicate={predicate} splits={splits} revision={preview.tree_revision} expectedRevision={preview.tree_revision} onRefreshPreview={onRefresh} selected={focused} onSelectEpoch={selectEpoch}/>:{selectEpoch(epoch.epoch_uuid);setTargets([]);toggleMetadata(true);setCellTagRequest(cell);}} targets={targets} setTargets={setTargets} disabled={!!page.error}/>
}{!metadataOpen&&{tagEntry} } }
- {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'&&
Browse epochs }
-
Change search criteria
+ {step==='tree'&&
Change search criteria }
{step==='tree'&&<>
Export >}
- }
+ {step==='results'&&}}
{error&&
{error}setError('')} aria-label="Dismiss error">
}
{annotationHold&&
Annotations changed. The current epoch page and selection are retained; refresh results before exporting or applying this query. Refresh results
}
{notice&&
{notice} setNotice('')} aria-label="Dismiss revision notice">
}
{initialCandidateLoading?
Loading saved candidate…
:initialRevisionId&&!applied&&!departedInitial?
The saved candidate could not be loaded. setInitialCandidateRetry(value=>value+1)}>Retry
:step==='filter'&&!focused?
usePreset(item,'edit')} onPin={item=>usePreset(item,'pin')}/>:step==='results'?
{destination&&applied&&!resultsFromDraft&&
setDestination(null)} onApplied={onProtocolApplied} onChanged={()=>onChange?.()}/>}
{resultError&&{resultError}resultsFromDraft?previewDraft():setGeneration(value=>value+1)}>Refresh results
}
- {!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'} saveRevision('tree')}> {changedMembership?'Save current revision':'Save tree revision'} >:Filter & tree recorded }
@@ -249,9 +250,11 @@ export default function MetadataExplorer({initialEditorOpen=false,openRequest=0,
{!focused&&applied&&Tree layout {setResultsFromDraft(false);setStep('results');}}>View matching epochs {setResultsFromDraft(false);setStep('results');prepareDestination('export',false);}}>Export selection
}
{!focused&&applied&&}
-
- {tree.data?:setGeneration(value=>value+1)}/>}
- {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/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 ;
+}
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'}]});
+});
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:''}]));
+});