audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: split search into explicit keyword/mood modes (#1310)

the Cmd+K modal was firing keyword and semantic (mood) searches in
parallel whenever the vibe-search flag was on, then merging both lists
by score (#858). BM25 relevance and cosine similarity are on different
scales — the sort produced jarring interleaves where mediocre semantic
matches outranked solid keyword hits.

revert to the #848 interaction model: explicit mode toggle, one mode at
a time. keyword is the default. flagged users can flip to mood when
they want it; the toggle is hidden for everyone else so there's no
change for non-flagged users.

- search.svelte.ts: add mode state, setMode(), stale-mode guards on
in-flight fetches. activeResults returns just the active mode's list.
drop dedupedSemanticResults / semanticResultIds / semanticSimilarityMap.
- SearchModal.svelte: render a small keyword/mood chip toggle below the
input, gated on search.semanticEnabled. placeholder copy follows the
active mode. mood similarity % only renders in semantic mode.

arc for the record: toggle (#848) → parallel + separator (#851) →
score-merged interleave (#858) → toggle again. #851/#858 were the wrong
direction given how uneven semantic ranking still is.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
a4977bc2 1dd1fa60

+144 -102
+70 -39
frontend/src/lib/components/SearchModal.svelte
··· 12 12 13 13 // sync semantic search availability from user flags 14 14 $effect(() => { 15 - search.semanticEnabled = auth.user?.enabled_flags?.includes(VIBE_SEARCH_FLAG) ?? false; 15 + const enabled = auth.user?.enabled_flags?.includes(VIBE_SEARCH_FLAG) ?? false; 16 + search.semanticEnabled = enabled; 17 + // clamp mode back to keyword when the flag flips off 18 + if (!enabled && search.mode === 'semantic') { 19 + search.setMode('keyword'); 20 + } 16 21 }); 17 22 18 23 // detect mobile on mount ··· 155 160 bind:this={inputRef} 156 161 type="text" 157 162 class="search-input" 158 - placeholder="search tracks, artists, albums, playlists..." 163 + placeholder={search.semanticEnabled && search.mode === 'semantic' 164 + ? 'describe a mood or vibe...' 165 + : 'search tracks, artists, albums, playlists...'} 159 166 value={search.query} 160 167 oninput={(e) => search.setQuery(e.currentTarget.value)} 161 168 maxlength="100" ··· 171 178 {/if} 172 179 </div> 173 180 181 + {#if search.semanticEnabled} 182 + <div class="search-mode-toggle" role="tablist" aria-label="search mode"> 183 + <button 184 + type="button" 185 + role="tab" 186 + aria-selected={search.mode === 'keyword'} 187 + class="mode-chip" 188 + class:active={search.mode === 'keyword'} 189 + onclick={() => search.setMode('keyword')} 190 + > 191 + keyword 192 + </button> 193 + <button 194 + type="button" 195 + role="tab" 196 + aria-selected={search.mode === 'semantic'} 197 + class="mode-chip" 198 + class:active={search.mode === 'semantic'} 199 + onclick={() => search.setMode('semantic')} 200 + > 201 + mood 202 + </button> 203 + </div> 204 + {/if} 205 + 174 206 <div class="search-body"> 175 207 {#if search.activeResults.length > 0} 176 208 <div class="search-results"> ··· 223 255 <span class="result-title">{getResultTitle(result)}</span> 224 256 <span class="result-subtitle">{getResultSubtitle(result)}</span> 225 257 </div> 226 - {#if result.type === 'track' && search.semanticResultIds.has(result.id)} 227 - {@const pct = Math.round((search.semanticSimilarityMap.get(result.id) ?? 0) * 100)} 258 + {#if search.mode === 'semantic' && result.type === 'track' && 'similarity' in result} 259 + {@const pct = Math.round(result.similarity * 100)} 228 260 <span class="result-type mood">{pct}%</span> 229 261 {:else} 230 262 <span class="result-type">{result.type}</span> 231 263 {/if} 232 264 </button> 233 265 {/each} 234 - {#if search.semanticLoading} 235 - <div class="semantic-loading"> 236 - <div class="search-spinner-small"></div> 237 - <span>searching by mood...</span> 238 - </div> 239 - {/if} 240 - </div> 241 - {:else if search.query.length >= 2 && !search.loading && search.semanticLoading} 242 - <div class="search-results"> 243 - <div class="search-empty">no matches by name</div> 244 - <div class="semantic-loading"> 245 - <div class="search-spinner-small"></div> 246 - <span>searching by mood...</span> 247 - </div> 248 266 </div> 249 267 {:else if search.query.length >= 2 && !search.loading && !search.semanticLoading && search.activeResults.length === 0} 250 268 <div class="search-empty"> ··· 358 376 animation: spin 0.6s linear infinite; 359 377 } 360 378 379 + .search-mode-toggle { 380 + display: flex; 381 + gap: 0.375rem; 382 + padding: 0.5rem 1.25rem; 383 + border-bottom: 1px solid var(--border-subtle); 384 + background: color-mix(in srgb, var(--bg-tertiary) 30%, transparent); 385 + } 386 + 387 + .mode-chip { 388 + font-family: inherit; 389 + font-size: 11px; 390 + text-transform: lowercase; 391 + letter-spacing: 0.02em; 392 + padding: 0.25rem 0.6rem; 393 + background: var(--bg-tertiary); 394 + color: var(--text-muted); 395 + border: 1px solid var(--border-subtle); 396 + border-radius: var(--radius-sm); 397 + cursor: pointer; 398 + transition: background 0.1s, color 0.1s, border-color 0.1s; 399 + } 400 + 401 + .mode-chip:hover { 402 + background: var(--bg-hover); 403 + color: var(--text-secondary); 404 + } 405 + 406 + .mode-chip.active { 407 + color: var(--accent); 408 + background: color-mix(in srgb, var(--accent) 10%, transparent); 409 + border-color: color-mix(in srgb, var(--accent) 35%, transparent); 410 + } 411 + 361 412 @keyframes spin { 362 413 to { 363 414 transform: rotate(360deg); ··· 490 541 background: color-mix(in srgb, var(--accent) 10%, transparent); 491 542 } 492 543 493 - .semantic-loading { 494 - display: flex; 495 - align-items: center; 496 - justify-content: center; 497 - gap: 0.5rem; 498 - padding: 0.75rem; 499 - color: var(--text-muted); 500 - font-size: var(--text-xs); 501 - } 502 - 503 - .search-spinner-small { 504 - width: 12px; 505 - height: 12px; 506 - border: 1.5px solid var(--border-default); 507 - border-top-color: var(--accent); 508 - border-radius: var(--radius-full); 509 - animation: spin 0.6s linear infinite; 510 - } 511 - 512 544 .search-empty { 513 545 padding: 2rem; 514 546 text-align: center; ··· 588 620 589 621 /* respect reduced motion */ 590 622 @media (prefers-reduced-motion: reduce) { 591 - .search-spinner, 592 - .search-spinner-small { 623 + .search-spinner { 593 624 animation: none; 594 625 } 595 626 }
+73 -62
frontend/src/lib/search.svelte.ts
··· 2 2 import { API_URL } from '$lib/config'; 3 3 4 4 export type SearchResultType = 'track' | 'artist' | 'album' | 'tag' | 'playlist'; 5 + export type SearchMode = 'keyword' | 'semantic'; 5 6 6 7 export interface TrackSearchResult { 7 8 type: 'track'; ··· 87 88 } 88 89 89 90 const MAX_QUERY_LENGTH = 100; 91 + const EMPTY_COUNTS: SearchResponse['counts'] = { 92 + tracks: 0, 93 + artists: 0, 94 + albums: 0, 95 + tags: 0, 96 + playlists: 0 97 + }; 90 98 91 99 class SearchState { 92 100 isOpen = $state(false); 93 101 query = $state(''); 94 102 results = $state<SearchResult[]>([]); 95 - counts = $state<SearchResponse['counts']>({ tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }); 103 + counts = $state<SearchResponse['counts']>({ ...EMPTY_COUNTS }); 96 104 loading = $state(false); 97 105 error = $state<string | null>(null); 98 106 selectedIndex = $state(0); ··· 102 110 semanticLoading = $state(false); 103 111 semanticAvailable = $state(true); 104 112 semanticResults = $state<SemanticSearchResult[]>([]); 113 + 114 + // mode toggle — only exposed to users with the vibe-search flag 115 + mode = $state<SearchMode>('keyword'); 105 116 106 117 // reference to input element for direct focus (mobile keyboard workaround) 107 118 inputRef: HTMLInputElement | null = null; ··· 124 135 this.query = ''; 125 136 this.results = []; 126 137 this.semanticResults = []; 127 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 138 + this.counts = { ...EMPTY_COUNTS }; 128 139 this.error = null; 129 140 this.selectedIndex = 0; 130 141 } ··· 135 146 this.results = []; 136 147 this.semanticResults = []; 137 148 this.error = null; 149 + this.mode = 'keyword'; 138 150 if (this.keywordTimeout) { 139 151 clearTimeout(this.keywordTimeout); 140 152 this.keywordTimeout = null; ··· 157 169 this.query = value; 158 170 this.selectedIndex = 0; 159 171 160 - // clear previous timeouts 172 + // always clear both timers — we never want a stale fetch racing the active mode 161 173 if (this.keywordTimeout) { 162 174 clearTimeout(this.keywordTimeout); 175 + this.keywordTimeout = null; 163 176 } 164 177 if (this.semanticTimeout) { 165 178 clearTimeout(this.semanticTimeout); 179 + this.semanticTimeout = null; 166 180 } 167 181 168 182 // validate length ··· 170 184 this.error = `query too long (max ${MAX_QUERY_LENGTH} characters)`; 171 185 this.results = []; 172 186 this.semanticResults = []; 173 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 187 + this.counts = { ...EMPTY_COUNTS }; 174 188 return; 175 189 } 176 190 177 191 this.error = null; 178 192 179 - // keyword search: 150ms debounce, min 2 chars 180 - // set loading=true immediately so the body doesn't flash "no results for X" 181 - // during the debounce window before the fetch actually fires 182 - if (value.length >= 2) { 183 - this.loading = true; 184 - this.keywordTimeout = setTimeout(() => { 185 - void this.search(value); 186 - }, 150); 193 + if (this.mode === 'keyword') { 194 + // fire keyword search only; clear any semantic state 195 + this.semanticResults = []; 196 + this.semanticLoading = false; 197 + if (value.length >= 2) { 198 + this.loading = true; 199 + this.keywordTimeout = setTimeout(() => { 200 + void this.search(value); 201 + }, 150); 202 + } else { 203 + this.loading = false; 204 + this.results = []; 205 + this.counts = { ...EMPTY_COUNTS }; 206 + } 187 207 } else { 188 - this.loading = false; 208 + // fire semantic search only; clear any keyword state 189 209 this.results = []; 190 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 210 + this.counts = { ...EMPTY_COUNTS }; 211 + this.loading = false; 212 + if (value.length >= 3) { 213 + this.semanticLoading = true; 214 + this.semanticTimeout = setTimeout(() => { 215 + void this.searchSemantic(value); 216 + }, 500); 217 + } else { 218 + this.semanticLoading = false; 219 + this.semanticResults = []; 220 + } 191 221 } 222 + } 192 223 193 - // semantic search: 500ms debounce, min 3 chars, only if enabled 194 - if (this.semanticEnabled && value.length >= 3) { 195 - this.semanticLoading = true; 196 - this.semanticTimeout = setTimeout(() => { 197 - void this.searchSemantic(value); 198 - }, 500); 199 - } else { 200 - this.semanticLoading = false; 201 - this.semanticResults = []; 224 + setMode(next: SearchMode) { 225 + if (next === this.mode) return; 226 + this.mode = next; 227 + this.results = []; 228 + this.semanticResults = []; 229 + this.counts = { ...EMPTY_COUNTS }; 230 + this.selectedIndex = 0; 231 + if (this.query.length >= 2) { 232 + this.setQuery(this.query); 202 233 } 203 234 } 204 235 205 236 async search(query: string): Promise<void> { 206 237 if (query.length < 2) return; 207 238 239 + const modeAtStart = this.mode; 208 240 this.loading = true; 209 241 this.error = null; 210 242 ··· 219 251 220 252 const data: SearchResponse = await response.json(); 221 253 222 - // stale query guard 223 - if (this.query !== query) return; 254 + // stale query or stale mode guard 255 + if (this.query !== query || this.mode !== modeAtStart) return; 224 256 225 257 this.results = data.results; 226 258 this.counts = data.counts; 227 259 this.selectedIndex = 0; 228 260 } catch (e) { 229 - if (this.query !== query) return; 261 + if (this.query !== query || this.mode !== modeAtStart) return; 230 262 console.error('search error:', e); 231 263 this.error = e instanceof Error ? e.message : 'search failed'; 232 264 this.results = []; 233 265 } finally { 234 - this.loading = false; 266 + if (this.mode === modeAtStart && this.query === query) { 267 + this.loading = false; 268 + } 235 269 } 236 270 } 237 271 238 272 async searchSemantic(query: string): Promise<void> { 239 273 if (query.length < 3) return; 240 274 275 + const modeAtStart = this.mode; 241 276 this.semanticLoading = true; 242 277 243 278 try { ··· 247 282 248 283 if (!response.ok) { 249 284 // non-ok but not a structured response — silently degrade 250 - this.semanticResults = []; 251 - this.semanticAvailable = false; 285 + if (this.query === query && this.mode === modeAtStart) { 286 + this.semanticResults = []; 287 + this.semanticAvailable = false; 288 + } 252 289 return; 253 290 } 254 291 255 292 const data: SemanticSearchResponse = await response.json(); 256 293 257 - // stale query guard 258 - if (this.query !== query) return; 294 + // stale query or stale mode guard 295 + if (this.query !== query || this.mode !== modeAtStart) return; 259 296 260 297 this.semanticAvailable = data.available; 261 298 this.semanticResults = data.available ? data.results : []; 262 299 this.selectedIndex = 0; 263 300 } catch (e) { 264 - if (this.query !== query) return; 301 + if (this.query !== query || this.mode !== modeAtStart) return; 265 302 console.error('semantic search error:', e); 266 303 this.semanticResults = []; 267 304 } finally { 268 - this.semanticLoading = false; 305 + if (this.mode === modeAtStart && this.query === query) { 306 + this.semanticLoading = false; 307 + } 269 308 } 270 309 } 271 310 272 - /** semantic results with keyword track IDs filtered out */ 273 - get dedupedSemanticResults(): SemanticSearchResult[] { 274 - const keywordTrackIds = new Set( 275 - this.results.filter((r) => r.type === 'track').map((r) => (r as TrackSearchResult).id) 276 - ); 277 - return this.semanticResults.filter((r) => !keywordTrackIds.has(r.id)); 278 - } 279 - 280 - /** IDs of results that came from semantic search (for badge rendering) */ 281 - get semanticResultIds(): Set<number> { 282 - return new Set(this.dedupedSemanticResults.map((r) => r.id)); 283 - } 284 - 285 - /** similarity scores keyed by track ID for badge display */ 286 - get semanticSimilarityMap(): Map<number, number> { 287 - return new Map(this.dedupedSemanticResults.map((r) => [r.id, r.similarity])); 288 - } 289 - 290 311 get activeResults(): (SearchResult | SemanticSearchResult)[] { 291 - const keyword = this.results; 292 - const semantic = this.dedupedSemanticResults; 293 - 294 - if (semantic.length === 0) return keyword; 295 - if (keyword.length === 0) return semantic; 296 - 297 - // merge by score — both relevance and similarity are 0-1 298 - const score = (r: SearchResult | SemanticSearchResult): number => 299 - 'similarity' in r ? r.similarity : r.relevance; 300 - 301 - return [...keyword, ...semantic].sort((a, b) => score(b) - score(a)); 312 + return this.mode === 'keyword' ? this.results : this.semanticResults; 302 313 } 303 314 304 315 selectNext() {
+1 -1
loq.toml
··· 108 108 109 109 [[rules]] 110 110 path = "frontend/src/lib/components/SearchModal.svelte" 111 - max_lines = 596 111 + max_lines = 627 112 112 113 113 [[rules]] 114 114 path = "frontend/src/lib/components/TrackActionsMenu.svelte"