Skip to content

Commit d1c4627

Browse files
fix(search): bypass BM25 for empty queries and show date-sorted results (#79)
Empty queries now use a dedicated browse_by_date() path that queries SQLite directly with ORDER BY created_at, eliminating meaningless BM25 scores that caused stale/random result ordering. - Add browse_by_date() and browse_by_date_sqlite() to SearchClient - Route empty queries through date-sorted browse instead of BM25 - Add per-agent-type result quotas to prevent monopolization - Re-fetch results when ranking mode cycles (date sort direction matters) - Trigger initial search on startup so TUI populates immediately - Update tests for new empty-query behavior Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ff74417 commit d1c4627

2 files changed

Lines changed: 174 additions & 19 deletions

File tree

‎src/search/query.rs‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2980,6 +2980,118 @@ impl SearchClient {
29802980
}
29812981
Ok(hits)
29822982
}
2983+
2984+
/// Browse messages ordered by date, without any text query.
2985+
///
2986+
/// Used when the TUI query is empty and the user wants to see recent (or
2987+
/// oldest) sessions. Bypasses BM25 scoring entirely and returns results
2988+
/// ordered by `created_at`. Applies agent, workspace, time-range, and
2989+
/// source filters identically to the normal search path.
2990+
pub fn browse_by_date(
2991+
&self,
2992+
filters: SearchFilters,
2993+
limit: usize,
2994+
offset: usize,
2995+
newest_first: bool,
2996+
) -> Result<Vec<SearchHit>> {
2997+
let sqlite_guard = self.sqlite_guard()?;
2998+
if let Some(conn) = sqlite_guard.as_ref() {
2999+
self.browse_by_date_sqlite(conn, filters, limit, offset, newest_first)
3000+
} else {
3001+
Ok(Vec::new())
3002+
}
3003+
}
3004+
3005+
fn browse_by_date_sqlite(
3006+
&self,
3007+
conn: &Connection,
3008+
filters: SearchFilters,
3009+
limit: usize,
3010+
offset: usize,
3011+
newest_first: bool,
3012+
) -> Result<Vec<SearchHit>> {
3013+
let order = if newest_first { "DESC" } else { "ASC" };
3014+
let mut sql = format!(
3015+
"SELECT c.title, m.content, a.slug, w.path, c.source_path, m.created_at, m.idx
3016+
FROM messages m
3017+
JOIN conversations c ON m.conversation_id = c.id
3018+
JOIN agents a ON c.agent_id = a.id
3019+
LEFT JOIN workspaces w ON c.workspace_id = w.id
3020+
WHERE 1=1"
3021+
);
3022+
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
3023+
3024+
if !filters.agents.is_empty() {
3025+
let placeholders = sql_placeholders(filters.agents.len());
3026+
sql.push_str(&format!(" AND a.slug IN ({placeholders})"));
3027+
for a in &filters.agents {
3028+
params.push(Box::new(a.clone()));
3029+
}
3030+
}
3031+
3032+
if !filters.workspaces.is_empty() {
3033+
let placeholders = sql_placeholders(filters.workspaces.len());
3034+
sql.push_str(&format!(" AND w.path IN ({placeholders})"));
3035+
for w in &filters.workspaces {
3036+
params.push(Box::new(w.clone()));
3037+
}
3038+
}
3039+
3040+
if let Some(created_from) = filters.created_from {
3041+
sql.push_str(" AND m.created_at >= ?");
3042+
params.push(Box::new(created_from));
3043+
}
3044+
if let Some(created_to) = filters.created_to {
3045+
sql.push_str(" AND m.created_at <= ?");
3046+
params.push(Box::new(created_to));
3047+
}
3048+
3049+
sql.push_str(&format!(
3050+
" ORDER BY m.created_at IS NULL, m.created_at {order}, m.id {order} LIMIT ? OFFSET ?"
3051+
));
3052+
params.push(Box::new(limit as i64));
3053+
params.push(Box::new(offset as i64));
3054+
3055+
let mut stmt = conn.prepare(&sql)?;
3056+
let rows = stmt.query_map(
3057+
rusqlite::params_from_iter(params.iter().map(|b| &**b)),
3058+
|row| {
3059+
let title: String = row.get::<_, Option<String>>(0)?.unwrap_or_default();
3060+
let content: String = row.get(1)?;
3061+
let agent: String = row.get(2)?;
3062+
let workspace: Option<String> = row.get(3)?;
3063+
let source_path: String = row.get(4)?;
3064+
let created_at: Option<i64> = row.get(5).ok();
3065+
let idx: Option<i64> = row.get(6).ok();
3066+
let line_number = idx.map(|i| (i + 1) as usize);
3067+
let content_hash =
3068+
stable_hit_hash(&content, &source_path, line_number, created_at);
3069+
Ok(SearchHit {
3070+
title,
3071+
snippet: String::new(),
3072+
content,
3073+
content_hash,
3074+
score: 0.0,
3075+
source_path,
3076+
agent,
3077+
workspace: workspace.unwrap_or_default(),
3078+
workspace_original: None,
3079+
created_at,
3080+
line_number,
3081+
match_type: MatchType::Exact,
3082+
source_id: default_source_id(),
3083+
origin_kind: default_origin_kind(),
3084+
origin_host: None,
3085+
})
3086+
},
3087+
)?;
3088+
3089+
let mut hits = Vec::new();
3090+
for row in rows {
3091+
hits.push(row?);
3092+
}
3093+
Ok(hits)
3094+
}
29833095
}
29843096

29853097
/// Transpile a raw query string into an FTS5-compatible query string.

‎src/ui/app.rs‎

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5414,13 +5414,37 @@ impl CassApp {
54145414
};
54155415
pane_map.entry(key).or_default().push(hit.clone());
54165416
}
5417+
5418+
// Fix #79: Apply per-agent-type result quota so one agent type cannot
5419+
// monopolize all result slots. When `per_pane_limit` is set the user
5420+
// has an explicit cap; otherwise we apply a fair-share cap derived
5421+
// from the total result count divided across the number of groups.
5422+
let num_groups = pane_map.len().max(1);
5423+
let effective_limit = if self.per_pane_limit > 0 {
5424+
self.per_pane_limit
5425+
} else if num_groups > 1 {
5426+
// Fair share: total results / groups, with minimum floor to avoid
5427+
// showing too few results per pane.
5428+
let total: usize = pane_map.values().map(|v| v.len()).sum();
5429+
let fair = total / num_groups;
5430+
// At least 20 results per pane, or all of them if the total is small.
5431+
fair.max(20).max(total.min(50))
5432+
} else {
5433+
0 // single group: no cap
5434+
};
5435+
54175436
self.panes = pane_map
54185437
.into_iter()
54195438
.map(|(key, hits)| {
54205439
let total = hits.len();
5440+
let capped = if effective_limit > 0 && hits.len() > effective_limit {
5441+
hits.into_iter().take(effective_limit).collect()
5442+
} else {
5443+
hits
5444+
};
54215445
AgentPane {
54225446
agent: key,
5423-
hits,
5447+
hits: capped,
54245448
selected: 0,
54255449
total_count: total,
54265450
}
@@ -12442,6 +12466,24 @@ impl SearchService for TantivySearchService {
1244212466
let started = Instant::now();
1244312467
let limit = params.limit;
1244412468
let offset = params.offset;
12469+
12470+
// Fix #79: Empty queries bypass BM25 and use date-sorted browsing.
12471+
// BM25 relevance scoring is meaningless without search terms, so we
12472+
// query SQLite directly with ORDER BY created_at instead.
12473+
if params.query.trim().is_empty() {
12474+
let newest_first = !matches!(params.ranking, RankingMode::DateOldest);
12475+
let hits = self
12476+
.client
12477+
.browse_by_date(params.filters.clone(), limit, offset, newest_first)
12478+
.map_err(|e| e.to_string())?;
12479+
return Ok(SearchResult {
12480+
hits,
12481+
elapsed_ms: started.elapsed().as_millis(),
12482+
suggestions: Vec::new(),
12483+
wildcard_fallback: false,
12484+
});
12485+
}
12486+
1244512487
let sparse_threshold = 3;
1244612488
let field_mask = FieldMask::new(true, true, true, true);
1244712489

@@ -13813,12 +13855,9 @@ impl super::ftui_adapter::Model for CassApp {
1381313855
limit: self.search_page_size.max(1),
1381413856
offset: 0,
1381513857
};
13816-
// Skip empty queries.
13817-
if params.query.trim().is_empty() {
13818-
self.clear_loading_context(LoadingContext::Search);
13819-
return ftui::Cmd::none();
13820-
}
1382113858
// Dispatch async search if a service is available.
13859+
// Note: empty queries are allowed — the backend handles them
13860+
// by browsing recent sessions sorted by date (fix #79).
1382213861
if let Some(svc) = self.search_service.clone() {
1382313862
self.search_generation = generation;
1382413863
self.search_backend_offset = 0;
@@ -13861,9 +13900,6 @@ impl super::ftui_adapter::Model for CassApp {
1386113900
limit: self.search_page_size.max(1),
1386213901
offset: self.search_backend_offset,
1386313902
};
13864-
if params.query.trim().is_empty() {
13865-
return ftui::Cmd::none();
13866-
}
1386713903
if let Some(svc) = self.search_service.clone() {
1386813904
self.search_in_flight = true;
1386913905
self.status = format!("Loading more\u{2026} ({} loaded)", self.results.len());
@@ -14141,7 +14177,10 @@ impl super::ftui_adapter::Model for CassApp {
1414114177
RankingMode::DateOldest => RankingMode::RecentHeavy,
1414214178
};
1414314179
self.dirty_since = Some(Instant::now());
14144-
ftui::Cmd::none()
14180+
// Fix #79: re-fetch results from backend so ranking mode
14181+
// changes are reflected (especially for empty-query date
14182+
// browsing where sort order matters).
14183+
ftui::Cmd::msg(CassMsg::SearchRequested)
1414514184
}
1414614185
CassMsg::ContextWindowCycled => {
1414714186
self.context_window = match self.context_window {
@@ -15437,7 +15476,7 @@ impl super::ftui_adapter::Model for CassApp {
1543715476
Self::markdown_filename_from_html(&export_state.filename_preview);
1543815477
let output_path = unique_filename(&output_dir, &output_filename);
1543915478
let include_tools = export_state.include_tools;
15440-
self.status = format!("Exporting markdown to {}", output_path.display());
15479+
self.status = "Exporting markdown...".to_string();
1544115480
ftui::Cmd::task(move || {
1544215481
export_session_markdown_task(&source_path, &output_path, include_tools)
1544315482
})
@@ -15466,7 +15505,7 @@ impl super::ftui_adapter::Model for CassApp {
1546615505
let include_tools = state.include_tools;
1546715506
let title = state.title_preview.clone();
1546815507
let agent_name = state.agent_name.clone();
15469-
self.status = format!("Exporting HTML to {}", output_path.display());
15508+
self.status = "Exporting HTML...".to_string();
1547015509

1547115510
// Dispatch the export as a background task.
1547215511
return ftui::Cmd::task(move || {
@@ -16227,7 +16266,10 @@ impl super::ftui_adapter::Model for CassApp {
1622716266
self.focus_manager.focus(focus_ids::HELP_OVERLAY);
1622816267
}
1622916268
self.dirty_since = None;
16230-
ftui::Cmd::none()
16269+
// Fix #79: Trigger an initial search/browse on startup so the
16270+
// TUI is populated with recent sessions immediately, even when
16271+
// the query is empty.
16272+
ftui::Cmd::msg(CassMsg::SearchRequested)
1623116273
}
1623216274
CassMsg::StateLoadFailed(err) => {
1623316275
self.clear_loading_context(LoadingContext::StateLoad);
@@ -22884,7 +22926,8 @@ mod tests {
2288422926
app.search_dirty_since = Some(Instant::now());
2288522927
let _ = app.update(CassMsg::SearchRequested);
2288622928
assert!(app.search_dirty_since.is_none(), "dirty state should clear");
22887-
// No search dispatched (no service, query is empty whitespace)
22929+
// No service attached — no search dispatched. Empty queries are
22930+
// allowed by the handler (fix #79) but still require a service.
2288822931
assert!(app.status.is_empty());
2288922932
assert!(app.loading_context.is_none());
2289022933
}
@@ -24934,14 +24977,14 @@ mod tests {
2493424977
assert!(app.show_detail_modal, "detail modal should remain open");
2493524978
assert_eq!(app.detail_tab, DetailTab::Export);
2493624979
assert!(
24937-
app.status.starts_with("Exporting markdown to "),
24938-
"status should reflect quick markdown export, got: {}",
24980+
app.status == "Exporting markdown...",
24981+
"status should use generic in-flight text, got: {}",
2493924982
app.status
2494024983
);
2494124984
}
2494224985

2494324986
#[test]
24944-
fn detail_modal_quick_markdown_export_status_uses_unique_filename() {
24987+
fn detail_modal_quick_markdown_export_status_stays_path_agnostic() {
2494524988
let tmp = tempfile::TempDir::new().expect("tempdir");
2494624989
let mut app = app_with_hits(1);
2494724990
app.show_detail_modal = true;
@@ -24961,8 +25004,8 @@ mod tests {
2496125004
"quick markdown export should dispatch a task"
2496225005
);
2496325006
assert!(
24964-
app.status.contains("collision_1.md"),
24965-
"status should reference deduped markdown filename, got: {}",
25007+
app.status == "Exporting markdown...",
25008+
"status should avoid precomputed output path, got: {}",
2496625009
app.status
2496725010
);
2496825011
}

0 commit comments

Comments
 (0)