-
setQuery(e.target.value)}
- autoFocus
- ref={searchRef}
- className="form-control"
- data-testid="reference-picker-search"
- />
-
-
- {SEARCH_FIELD_OPTIONS.map(s => (
-
)
}
diff --git a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts
index 7e4c560526..2b58e8a888 100644
--- a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts
+++ b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts
@@ -13,7 +13,7 @@ function parseTokens(
text: string
): { value: string; start: number; end: number }[] {
const tokens: { value: string; start: number; end: number }[] = []
- const re = /[^\s,]+/g
+ const re = /[^\s,%]+/g
let m
while ((m = re.exec(text)) !== null) {
tokens.push({ value: m[0], start: m.index, end: m.index + m[0].length })
@@ -49,18 +49,10 @@ function openPickerIfInCite(view: EditorView): boolean {
const selTo = Math.min(braceTo, mainSel.to)
const hasSelection = selFrom < selTo
+ const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
if (hasSelection) {
const selectedText = view.state.doc.sliceString(selFrom, selTo)
-
- // Don't open modal if the selection contains unpaired braces
- const opens = (selectedText.match(/{/g) || []).length
- const closes = (selectedText.match(/}/g) || []).length
- if (opens !== closes) {
- return startCompletion(view)
- }
-
// Parse the full cite content into tokens for partial-key expansion
- const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
const tokens = parseTokens(fullContent)
// Relative selection offsets within the cite content
@@ -92,20 +84,31 @@ function openPickerIfInCite(view: EditorView): boolean {
})
)
} else {
- // No selection — insert at cursor position
+ let insertPos = pos
+ // If the insert position is inside a token, move it forward
+ const relPos = pos - braceFrom
+ if (relPos > 0 && !/[\s,%]/.test(fullContent[relPos - 1])) {
+ for (let j = relPos; j <= fullContent.length; j++) {
+ const ch = fullContent[j]
+ if (j === fullContent.length || /[\s,%]/.test(ch)) {
+ insertPos = braceFrom + j
+ break
+ }
+ }
+ }
+
window.dispatchEvent(
new CustomEvent('reference:openPicker', {
detail: {
braceFrom,
braceTo,
- insertFrom: pos,
- insertTo: pos,
+ insertFrom: insertPos,
+ insertTo: insertPos,
selectedTokens: [],
},
})
)
}
-
return true
}
} catch {
diff --git a/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts b/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts
new file mode 100644
index 0000000000..2e361e40a8
--- /dev/null
+++ b/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts
@@ -0,0 +1,97 @@
+import { ReferenceIndex } from '@/features/ide-react/references/reference-index'
+import type {
+ Changes,
+ Bib2JsonEntry,
+ AdvancedReferenceSearchResult,
+} from '@/features/ide-react/references/types'
+
+export default class AdvancedReferenceIndex extends ReferenceIndex {
+ fileIndex: Map
> = new Map()
+ entryIndex: Map = new Map()
+
+ updateIndex({ updates, deletes }: Changes): Set {
+ for (const path of deletes) {
+ const keys = this.fileIndex.get(path)
+ if (keys) {
+ for (const k of keys) {
+ this.entryIndex.delete(k)
+ }
+ }
+ this.fileIndex.delete(path)
+ }
+
+ for (const { path, content } of updates) {
+ const previous = this.fileIndex.get(path)
+ if (previous) {
+ for (const k of previous) {
+ this.entryIndex.delete(k)
+ }
+ }
+
+ const fileReferences = new Set()
+ const entries = this.parseEntries(content)
+
+ for (const entry of entries) {
+ fileReferences.add(entry.EntryKey)
+ this.entryIndex.set(entry.EntryKey, entry)
+ }
+
+ this.fileIndex.set(path, fileReferences)
+ }
+
+ this.keys = new Set(
+ this.fileIndex.values().flatMap(entry => Array.from(entry))
+ )
+
+ return this.keys
+ }
+
+ async search(query: string): Promise {
+ const q = (query || '').toLowerCase().trim()
+
+ // Empty query: return all entries
+ if (!q) {
+ return this.list()
+ }
+
+ const tokens = q.split(/\s+/).filter(Boolean)
+
+ const hits: { _source: Bib2JsonEntry }[] = []
+
+ for (const entry of this.entryIndex.values()) {
+
+ const match = tokens.every(token =>
+ this.matchesAnyField(entry, token)
+ )
+
+ if (match) {
+ hits.push({ _source: entry })
+ }
+ }
+
+ return { hits }
+ }
+
+ private list(limit: number = 0): AdvancedReferenceSearchResult {
+ const results: { _source: Bib2JsonEntry }[] = []
+ for (const entry of this.entryIndex.values()) {
+ if (limit && results.length >= limit) break
+ results.push({ _source: entry })
+ }
+ return { hits: results }
+ }
+
+ private matchesAnyField(entry: Bib2JsonEntry, token: string): boolean {
+ const t = token.toLowerCase()
+ const f = entry.Fields
+
+ return (
+ entry.EntryKey.toLowerCase().includes(t) ||
+ f.author.toLowerCase().includes(t) ||
+ f.journal.toLowerCase().includes(t) ||
+ f.title.toLowerCase().includes(t) ||
+ f.date.toLowerCase().includes(t) ||
+ f.year.toLowerCase().includes(t)
+ )
+ }
+}
diff --git a/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts b/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts
deleted file mode 100644
index 8469dd37ff..0000000000
--- a/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { ReferenceIndex } from '@/features/ide-react/references/reference-index'
-import type {
- Changes,
- Bib2JsonEntry,
- AdvancedReferenceSearchResult,
-} from '@/features/ide-react/references/types'
-import Fuse, { IFuseOptions, FuseResult } from 'fuse.js'
-
-const MAX_RESULTS = 50
-
-export default class EnhancedReferenceIndex extends ReferenceIndex {
- fileIndex: Map> = new Map()
- entryIndex: Map = new Map()
- fuse: Fuse | null = null
-
- updateIndex({ updates, deletes }: Changes): Set {
- for (const path of deletes) {
- const keys = this.fileIndex.get(path)
- if (keys) {
- for (const k of keys) {
- this.entryIndex.delete(k)
- }
- }
- this.fileIndex.delete(path)
- }
-
- for (const { path, content } of updates) {
- const previous = this.fileIndex.get(path)
- if (previous) {
- for (const k of previous) {
- this.entryIndex.delete(k)
- }
- }
- const fileReferences = new Set()
- const entries = this.parseEntries(content)
- for (const entry of entries) {
- fileReferences.add(entry.EntryKey)
- this.entryIndex.set(entry.EntryKey, entry)
- }
- this.fileIndex.set(path, fileReferences)
- }
-
- this.keys = new Set(
- this.fileIndex.values().flatMap(entry => Array.from(entry))
- )
- this.rebuildFuseIndex()
- return this.keys
- }
-
- private rebuildFuseIndex() {
- const data = Array.from(this.entryIndex.values())
- try {
- const options: IFuseOptions = {
- includeScore: true,
- includeMatches: true,
- threshold: 0.35,
- ignoreLocation: true,
- keys: [
- { name: 'EntryKey', weight: 0.8 },
- { name: 'Fields.title', weight: 0.6 },
- { name: 'Fields.author', weight: 0.5 },
- { name: 'Fields.journal', weight: 0.3 },
- { name: 'Fields.year', weight: 0.2 },
- ],
- }
- this.fuse = new Fuse(data, options)
- } catch {
- this.fuse = null
- }
- }
-
- async search(query: string): Promise {
- const q = (query || '').toLowerCase().trim()
-
- // Empty query: return all entries (used by the picker to list references)
- if (!q) {
- return this.list(MAX_RESULTS)
- }
-
- const tokens = q.split(/\s+/).filter(Boolean)
- const isSingleYear = tokens.length === 1 && /^[0-9]{4}$/.test(tokens[0])
-
- // Exact year match shortcut
- if (isSingleYear) {
- const yearHits = this.substringScan(q, entry =>
- (entry.Fields.year || '').trim() === tokens[0]
- )
- if (yearHits.length > 0) return { hits: yearHits }
- }
-
- // Short queries or no Fuse index: substring scan
- if (q.length <= 2 || !this.fuse) {
- return { hits: this.substringScan(q) }
- }
-
- // Fuse-based fuzzy search
- try {
- return { hits: this.fuseSearch(q, tokens) }
- } catch {
- return { hits: this.substringScan(q) }
- }
- }
-
- private list(limit: number): AdvancedReferenceSearchResult {
- const results: { _source: Bib2JsonEntry }[] = []
- for (const entry of this.entryIndex.values()) {
- if (results.length >= limit) break
- results.push({ _source: entry })
- }
- return { hits: results }
- }
-
- private matchesAnyField(entry: Bib2JsonEntry, q: string): boolean {
- if (entry.EntryKey.toLowerCase().includes(q)) return true
- const f = entry.Fields
- if (f.title?.toLowerCase().includes(q)) return true
- if (f.author?.toLowerCase().includes(q)) return true
- if (f.journal?.toLowerCase().includes(q)) return true
- if (f.year?.toLowerCase().includes(q)) return true
- return false
- }
-
- private substringScan(
- q: string,
- predicate?: (entry: Bib2JsonEntry) => boolean
- ): { _source: Bib2JsonEntry }[] {
- const results: { _source: Bib2JsonEntry }[] = []
- for (const entry of this.entryIndex.values()) {
- if (results.length >= MAX_RESULTS) break
- if (predicate ? predicate(entry) : this.matchesAnyField(entry, q)) {
- results.push({ _source: entry })
- }
- }
- return results
- }
-
- private tokenSubstringScan(
- tokens: string[]
- ): { _source: Bib2JsonEntry }[] {
- const results: { _source: Bib2JsonEntry }[] = []
- for (const entry of this.entryIndex.values()) {
- if (results.length >= MAX_RESULTS) break
- const match = tokens.every(t => this.matchesAnyField(entry, t))
- if (match) results.push({ _source: entry })
- }
- return results
- }
-
- private fuseSearch(
- q: string,
- tokens: string[]
- ): { _source: Bib2JsonEntry }[] {
- const fuseResults = this.fuse!.search(q, { limit: MAX_RESULTS }) as FuseResult[]
-
- let mapped = fuseResults.map(r => ({ _source: r.item }))
-
- // Supplement with substring matches to catch anything Fuse missed
- const seen = new Set(mapped.map(m => m._source.EntryKey))
- const extra = this.tokenSubstringScan(tokens)
- .filter(r => !seen.has(r._source.EntryKey))
- if (extra.length) {
- mapped = [...mapped, ...extra]
- }
-
- return mapped
- }
-}
diff --git a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss b/services/web/modules/reference-picker/frontend/styles/reference-picker.scss
index 3d86f9bb66..3b1aaf659a 100644
--- a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss
+++ b/services/web/modules/reference-picker/frontend/styles/reference-picker.scss
@@ -1,127 +1,38 @@
-.reference-picker {
- display: flex;
- flex-direction: column;
- gap: var(--spacing-02);
-
- input[type='search'],
- input[type='text'] {
- width: 100%;
- padding: var(--spacing-03);
- border: 1px solid var(--border-divider);
- border-radius: var(--border-radius-small);
- font-size: var(--font-size-03);
- }
-
- .selected-chips {
+.references-search-modal {
+ .selected-key-tag {
+ position: relative;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-02);
align-items: center;
- max-height: 72px;
+ min-height: 30px;
+ max-height: 58px;
overflow-y: auto;
- }
-
- .search-selectors {
- display: flex;
- gap: var(--spacing-02);
- align-items: center;
font-size: var(--font-size-02);
+ margin-top: -10px;
+ margin-bottom: 8px;
+ box-shadow: 0 1px 0 var(--ds-color-neutral-100);
+ padding-bottom: 6px;
+
+ .badge-tag {
+ background-color:var(--ds-color-green-50) !important;
+ }
}
- .search-selector-label {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- }
-
- [role='listbox'] {
- max-height: calc(100vh - 320px);
- overflow-y: auto;
- border-top: 1px solid var(--border-divider);
- border-bottom: 1px solid var(--border-divider);
- }
-
- label[role='option'] {
- display: flex;
- align-items: flex-start;
- gap: var(--spacing-01);
- padding: var(--spacing-02);
- cursor: pointer;
- border-left: var(--spacing-02) solid transparent;
- outline: none;
- }
-
- label[role='option'].focused,
- label[role='option']:focus {
- background: var(--bg-secondary);
- border-left-color: var(--content-primary);
- box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.03) inset;
- }
-
- label[role='option'][aria-selected='true'] {
- font-weight: bold;
- color: var(--content-positive);
- }
-
- label[role='option'] input[type='checkbox'] {
- margin: 0;
- flex: 0 0 auto;
- align-self: flex-start;
- }
-
- .hit-head {
- display: flex;
- align-items: center;
- gap: var(--spacing-01);
- flex: 0 0 auto;
- }
-
- .hit-head .hit-key {
- display: inline-block;
- width: auto;
- max-width: calc(100% - 80px);
- }
-
- .hit-key {
- font-weight: 600;
- font-size: var(--font-size-02);
- min-width: 0;
- flex: 1 1 auto;
- color: var(--content-secondary);
- text-align: left;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .hit-main {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-width: 0;
- }
-
- .hit-title {
- font-size: var(--font-size-02);
- font-style: italic;
- color: var(--content-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .hit-meta {
- font-size: var(--font-size-02);
- color: var(--content-secondary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .reference-picker-empty {
- padding: var(--spacing-04);
- text-align: center;
- color: var(--content-secondary);
+ .search-results {
+ .search-results-scroll-container {
+ max-height: calc(100vh - 330px);
+ overflow-y: auto;
+ }
+ .search-result-hit {
+ &:focus {
+ background: var(--ds-color-yellow-50);
+ outline: none;
+ }
+ }
+ .found-token {
+ color: var(--ds-color-red-600);
+ font-weight: bold;
+ }
}
}
diff --git a/services/web/package.json b/services/web/package.json
index 1d03b64cff..2f58539770 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -79,6 +79,7 @@
"last 1 year",
"safari > 14"
],
+
"dependencies": {
"@ai-sdk/mcp": "^1.0.0",
"@ai-sdk/openai": "^3.0.0",
@@ -139,7 +140,6 @@
"express": "4.22.1",
"file-type": "^21.0.0",
"focus-trap-react": "^11.0.4",
- "fuse.js": "^7.0.0",
"globby": "^5.0.0",
"helmet": "^6.0.1",
"https-proxy-agent": "^7.0.6",
@@ -423,4 +423,4 @@
"yup": "^0.32.11",
"zustand": "^5.0.1"
}
-}
\ No newline at end of file
+}