From 558941a3debb257f6ede8f958e354f7537528e80 Mon Sep 17 00:00:00 2001 From: Tom Bates Date: Thu, 23 Jul 2026 11:39:12 -0300 Subject: [PATCH] fix(relay): upgrade profile-only prefix typeahead to substring matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped desktop and mobile builds send search_mode="prefix" for user pickers over kinds:[0] and can't be retroactively taught "contains". Every prefix hit is a substring hit and profile-only searches are always picker typeaheads, so the bridge maps that shape to contains — installed apps get mid-token matching as soon as the relay deploys, no client update needed. Message typeahead queries other kinds and keeps true prefix matching. Co-authored-by: Cursor --- crates/buzz-relay/src/api/bridge.rs | 57 ++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 9175100e2..9e0d5a1e8 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -346,6 +346,26 @@ fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode { } } +/// Upgrade profile-only prefix typeahead to substring matching. +/// +/// Shipped clients (desktop, mobile) send `search_mode: "prefix"` for their +/// user-picker typeahead over exactly `kinds:[0]` and expect any typed +/// fragment of a name to match — but names are single FTS lexemes, so prefix +/// matching misses mid-name fragments ("kurs" never finds "mattkursmark"). +/// Every prefix hit is a substring hit, so mapping these queries to +/// `Contains` fixes installed apps without a client update. Message +/// typeahead queries other kinds and keeps true prefix matching. +fn effective_search_mode( + mode: buzz_search::SearchMode, + kinds: Option<&[i32]>, +) -> buzz_search::SearchMode { + if mode == buzz_search::SearchMode::Prefix && kinds == Some(&[0]) { + buzz_search::SearchMode::Contains + } else { + mode + } +} + fn extract_search_page(raw: &Value) -> u32 { raw.get("page") .or_else(|| raw.get("search_page")) @@ -1684,6 +1704,7 @@ async fn handle_bridge_search( }); let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); + let mode = effective_search_mode(search_mode, kinds.as_deref()); let search_query = buzz_search::SearchQuery { community: tenant.community(), @@ -1695,7 +1716,7 @@ async fn handle_bridge_search( until, page: search_page, per_page: limit, - mode: search_mode, + mode, }; let search_result = state @@ -2264,6 +2285,40 @@ mod tests { ); } + #[test] + fn profile_only_prefix_typeahead_upgrades_to_contains() { + use buzz_search::SearchMode; + + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[0])), + SearchMode::Contains, + "user-picker typeahead from shipped clients gets substring matching" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[9, 40002])), + SearchMode::Prefix, + "message typeahead keeps true prefix" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[0, 9])), + SearchMode::Prefix, + "mixed-kind searches keep true prefix" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, None), + SearchMode::Prefix + ); + assert_eq!( + effective_search_mode(SearchMode::FullText, Some(&[0])), + SearchMode::FullText, + "whole-word search is never silently widened" + ); + assert_eq!( + effective_search_mode(SearchMode::Contains, Some(&[0])), + SearchMode::Contains + ); + } + #[test] fn bridge_search_mode_extension_accepts_contains() { assert_eq!(