Show an indexing-progress banner during best-match searches

While a best-match search runs against a partially built embeddings
index, results cover only the indexed items and can look arbitrary.
Show the indexing progress in a banner above the items list, updating
as the index fills, so incomplete results aren't mistaken for a
complete ranking.
This commit is contained in:
Dan Stillman 2026-07-21 13:48:15 -04:00
parent c5d41229bb
commit bb528d278a
6 changed files with 201 additions and 7 deletions

View file

@ -171,6 +171,62 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
return this._bestMatchBarFractions || new Map();
}
/**
* Embedding-index coverage while a best-match search is active, for the
* banner above the items list. Null when there's no active best-match
* search or every eligible item is indexed.
*
* @returns {Object|null} - { type: 'indexing'|'paused', indexed, total }
*/
getBestMatchIndexState() {
return this._bestMatchIndexState || null;
}
/**
* Compute the current index coverage across the selected rows' libraries.
* Never throws -- the banner is informational and shouldn't break a
* refresh.
*
* @return {Promise<Object|null>}
*/
async _getBestMatchIndexState() {
try {
let status = Zotero.Embeddings.Indexing.getStatus();
if (!status.enabled) {
return null;
}
// Counts aren't populated until the indexer runs in this session
if (!status.libraries.length) {
status = await Zotero.Embeddings.Indexing.refreshStatus();
}
let libraryIDs = new Set(
this.collectionTreeRows
.map(row => row.ref?.libraryID)
.filter(id => id !== undefined)
);
let libraries = status.libraries
.filter(lib => !libraryIDs.size || libraryIDs.has(lib.libraryID));
let indexed = libraries.reduce((sum, lib) => sum + lib.indexed, 0);
let total = libraries.reduce((sum, lib) => sum + lib.eligible, 0);
if (indexed >= total) {
return null;
}
// Only an explicit pause reports as paused. Anything else --
// between runs (startup, the pre-run debounce) or after an error
// (detailed in the preferences) -- reports as indexing, since the
// banner explains the incomplete coverage, not the indexer state
return {
type: status.paused ? 'paused' : 'indexing',
indexed,
total
};
}
catch (e) {
Zotero.logError(e);
return null;
}
}
/**
* The semantic stage of a best-match search: score the merged,
* deduplicated results from all selected rows against the query in a
@ -235,6 +291,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
Zotero.logError(e);
}
this._bestMatchRanks = new Map();
this._bestMatchIndexState = await this._getBestMatchIndexState();
// A rank-only search's membership doesn't depend on the index, so
// show its results unranked; anything else shows no results rather
// than an unranked scope
@ -274,6 +331,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
}
this._bestMatchRanks = ranks;
this._bestMatchBarFractions = fractions;
this._bestMatchIndexState = await this._getBestMatchIndexState();
return kept;
}
@ -511,6 +569,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
this.collectionTreeRows.forEach(row => row.clearCache());
this._bestMatchRanks = null;
this._bestMatchBarFractions = null;
this._bestMatchIndexState = null;
// Get the full set of items we want to show, merged across all selected rows
let newSearchItemSet = new Set();
for (let arr of await Promise.all(this.collectionTreeRows.map(row => row.getItems()))) {
@ -1354,6 +1413,30 @@ class CollectionViewItemTree extends ItemTree {
return super.sort(itemIDs);
}
/**
* While a best-match search runs against a partially built embeddings
* index, show a banner above the items list with the indexing progress,
* so incomplete results aren't mistaken for a complete ranking. Updates
* arrive with the periodic refreshes the indexer triggers as it fills
* the index.
*/
_renderTablePrologue() {
let state = this.rowProvider.getBestMatchIndexState();
if (!state) {
return null;
}
return (
<div
className="best-match-index-banner"
key="best-match-index-banner"
data-l10n-id={state.type == 'paused'
? 'items-best-match-indexing-paused'
: 'items-best-match-indexing'}
data-l10n-args={JSON.stringify({ indexed: state.indexed, total: state.total })}
/>
);
}
render() {
const showMessage = !this.collectionTreeRows.length || this._itemsPaneMessage;

View file

@ -509,6 +509,9 @@ class VirtualizedTable extends React.Component {
// Render with display: none
hide: PropTypes.bool,
// An element rendered above the column header (e.g. a status banner)
prologue: PropTypes.element,
multiSelect: PropTypes.bool,
requireSelection: PropTypes.bool,
@ -1420,6 +1423,7 @@ class VirtualizedTable extends React.Component {
return (
<div {...props}>
{columnDragMarker}
{this.props.prologue}
{header}
<div {...jsWindowProps}>
{/* Pinned copy of the current section's header. Lives inside the scrolling

View file

@ -1322,14 +1322,18 @@ var ItemTree = class ItemTree extends LibraryTree {
}
// A refresh can change the derived column set (e.g. the forced
// Relevance column while a best-match search is active), and the
// header only picks that up through a render
// Relevance column while a best-match search is active) or the table
// prologue, and the header and prologue only pick those up through a
// render
this._getColumns();
if (this.tree && this._renderedColumnsId !== this._columnsId) {
let columnsChanged = this._renderedColumnsId !== this._columnsId;
if (this.tree && (columnsChanged || this._renderedPrologueId !== this._getPrologueId())) {
await new Promise(resolve => this.forceUpdate(resolve));
// The rows above were painted with the previous column set, and the
// render only rebuilds the header
this.tree.invalidate();
if (columnsChanged) {
// The rows above were painted with the previous column set, and
// the render only rebuilds the header
this.tree.invalidate();
}
}
const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this;
@ -1478,13 +1482,37 @@ var ItemTree = class ItemTree extends LibraryTree {
</div>);
}
/**
* An element to render above the table's column header (e.g. a status
* banner). Subclasses override this; the base tree renders nothing.
*
* @return {React.Element|null}
*/
_renderTablePrologue() {
return null;
}
/**
* Identity of the current prologue's content, for handleRowModelUpdate()
* to detect when a refresh changed the prologue and a render is needed
*
* @return {String|null}
*/
_getPrologueId() {
let prologue = this._renderTablePrologue();
return prologue ? JSON.stringify(prologue.props) : null;
}
render() {
const showMessage = !!this._itemsPaneMessage;
const itemsPaneMessage = this._renderItemsPaneMessage(showMessage);
let columns = this._getColumns();
// The columns the header currently shows, for handleRowModelUpdate()
// The columns and prologue the table currently shows, for
// handleRowModelUpdate()
this._renderedColumnsId = this._columnsId;
let prologue = this._renderTablePrologue();
this._renderedPrologueId = prologue ? JSON.stringify(prologue.props) : null;
let virtualizedTable = React.createElement(VirtualizedTree,
{
getRowCount: () => this.rowProvider.getRowCount(),
@ -1492,6 +1520,7 @@ var ItemTree = class ItemTree extends LibraryTree {
ref: ref => this.tree = ref,
treeboxRef: ref => this._treebox = ref,
renderItem: this._renderItem.bind(this),
prologue,
hide: showMessage,
key: "virtualized-table",

View file

@ -458,6 +458,9 @@ items-column-last-read = Last Read
items-column-relevance = Relevance
items-column-relevance-rank = Rank { $rank }
items-best-match-indexing = Indexing in progress — { $indexed } of { $total } items indexed
items-best-match-indexing-paused = Indexing is paused — { $indexed } of { $total } items indexed
report-error =
.label = Report Error…

View file

@ -12,6 +12,17 @@
min-width: $min-width-items-pane;
}
// Indexing-progress banner above the column header while a best-match search
// runs against a partially built embeddings index
.virtualized-table .best-match-index-banner {
flex-shrink: 0;
padding: 4px 8px;
background: var(--material-sidepane);
border-bottom: var(--material-panedivider);
color: var(--fill-secondary);
text-align: center;
}
#zotero-items-tree {
.virtualized-table-body, .drag-image-container {
padding: 4px 8px 8px;

View file

@ -208,6 +208,13 @@ describe("CollectionViewItemTree", function () {
beforeEach(function () {
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
// A fully built index by default, so no indexing banner appears
stubs.push(sinon.stub(Zotero.Embeddings.Indexing, 'getStatus').returns({
enabled: true,
indexing: false,
paused: false,
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 0, eligible: 0 }]
}));
Zotero.Prefs.set('search.quicksearch-mode', 'bestMatch');
});
@ -330,6 +337,63 @@ describe("CollectionViewItemTree", function () {
}
});
it("should show an indexing-progress banner while the index is incomplete", async function () {
let col = await createDataObject('collection');
let item = await createDataObject('item', { title: "A", collections: [col.id] });
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
.resolves(new Map([[item.id, 0.7]])));
Zotero.Embeddings.Indexing.getStatus.returns({
enabled: true,
indexing: true,
paused: false,
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
});
await select(win, col);
itemsView = zp.itemsView;
await itemsView.setFilter('search', 'some query');
// Localization is async, so poll for the translated counts (the
// test times out on failure)
let banner = win.document.querySelector('.best-match-index-banner');
assert.ok(banner);
while (!/752 of 9,553/.test(banner.textContent)) {
await Zotero.Promise.delay(10);
}
// The paused wording requires the explicit paused flag -- the
// indexer merely not running at the moment (startup, the pre-run
// debounce) still reports indexing
Zotero.Embeddings.Indexing.getStatus.returns({
enabled: true,
indexing: false,
paused: false,
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
});
await itemsView.setFilter('search', 'between runs query');
banner = win.document.querySelector('.best-match-index-banner');
assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing');
Zotero.Embeddings.Indexing.getStatus.returns({
enabled: true,
indexing: false,
paused: true,
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
});
await itemsView.setFilter('search', 'paused query');
banner = win.document.querySelector('.best-match-index-banner');
assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing-paused');
// A complete index shows no banner
Zotero.Embeddings.Indexing.getStatus.returns({
enabled: true,
indexing: false,
paused: false,
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 9553, eligible: 9553 }]
});
await itemsView.setFilter('search', 'another query');
assert.notOk(win.document.querySelector('.best-match-index-banner'));
});
it("should score once across a multi-collection selection", async function () {
let col1 = await createDataObject('collection');
let col2 = await createDataObject('collection');