Skip to content

Commit 4e8320e

Browse files
Search UX tooltip and distinct /api/search error codes (#117)
Add a search-window info tooltip, return structured 400/404/503/500 responses with machine-readable codes, validate query length and workspace hash, and show API error messages in the search UI.
1 parent fb97d03 commit 4e8320e

4 files changed

Lines changed: 298 additions & 139 deletions

File tree

api/search.py

Lines changed: 183 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,118 +1,183 @@
1-
"""
2-
API route for search — mirrors src/app/api/search/route.ts
3-
GET /api/search?q=...&type=all|chat|composer&all_history=1
4-
"""
5-
6-
import logging
7-
from typing import Any
8-
9-
from flask import Blueprint, Response, current_app, request
10-
11-
from api.flask_config import json_response
12-
13-
from models import ParseWarningCollector, SearchResult
14-
from services.search import (
15-
DEFAULT_SEARCH_WINDOW_DAYS,
16-
rank_results,
17-
resolve_search_since_ms,
18-
search_cli_sessions,
19-
search_global_storage,
20-
search_legacy_workspaces,
21-
)
22-
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path
23-
24-
bp = Blueprint("search", __name__)
25-
_logger = logging.getLogger(__name__)
26-
27-
_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
28-
29-
30-
def _parse_since_days_param(raw: str | None) -> int | None:
31-
if raw is None or not str(raw).strip():
32-
return None
33-
try:
34-
days = int(raw)
35-
except ValueError:
36-
return None
37-
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
38-
return None
39-
return days
40-
41-
42-
@bp.route("/api/search")
43-
def search() -> tuple[Response, int] | Response:
44-
"""Search chats, composers, and CLI sessions across Cursor storage.
45-
46-
Args:
47-
q: Search query string (required; 400 when empty).
48-
type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
49-
50-
Returns:
51-
JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
52-
empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure.
53-
"""
54-
try:
55-
query = request.args.get("q", "").strip()
56-
search_type = request.args.get("type", "all")
57-
rules = current_app.config.get("EXCLUSION_RULES") or []
58-
all_history = request.args.get("all_history") in ("1", "true")
59-
since_ms = resolve_search_since_ms(
60-
all_history=all_history,
61-
since_days=_parse_since_days_param(request.args.get("since_days")),
62-
)
63-
64-
if not query:
65-
return json_response({"error": "No search query provided"}, 400)
66-
workspace_path = resolve_workspace_path()
67-
parse_warnings = ParseWarningCollector()
68-
query_lower = query.lower()
69-
70-
results: list[SearchResult] = []
71-
if search_type != "chat":
72-
results.extend(
73-
search_global_storage(
74-
workspace_path,
75-
query,
76-
query_lower,
77-
rules,
78-
parse_warnings,
79-
since_ms=since_ms,
80-
)
81-
)
82-
results.extend(
83-
search_legacy_workspaces(
84-
workspace_path,
85-
query,
86-
query_lower,
87-
search_type,
88-
rules,
89-
since_ms=since_ms,
90-
)
91-
)
92-
if search_type == "all":
93-
results.extend(
94-
search_cli_sessions(
95-
get_cli_chats_path(),
96-
query,
97-
query_lower,
98-
rules,
99-
parse_warnings,
100-
since_ms=since_ms,
101-
)
102-
)
103-
104-
payload: dict[str, Any] = {
105-
"results": rank_results(results),
106-
"allHistory": since_ms is None,
107-
"searchWindowDays": (
108-
None if since_ms is None else (
109-
_parse_since_days_param(request.args.get("since_days"))
110-
or DEFAULT_SEARCH_WINDOW_DAYS
111-
)
112-
),
113-
}
114-
return json_response(parse_warnings.attach_to(payload))
115-
116-
except Exception:
117-
_logger.exception("Search failed")
118-
return json_response({"error": "Search failed", "results": []}, 500)
1+
"""
2+
API route for search — mirrors src/app/api/search/route.ts
3+
GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace=<id>
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
import os
10+
import sqlite3
11+
from typing import Any
12+
13+
from flask import Blueprint, Response, current_app, request
14+
15+
from api.flask_config import json_response
16+
17+
from models import ParseWarningCollector, SearchResult
18+
from services.search import (
19+
DEFAULT_SEARCH_WINDOW_DAYS,
20+
rank_results,
21+
resolve_search_since_ms,
22+
search_cli_sessions,
23+
search_global_storage,
24+
search_legacy_workspaces,
25+
)
26+
from utils.cli_chat_reader import list_cli_projects
27+
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path
28+
29+
bp = Blueprint("search", __name__)
30+
_logger = logging.getLogger(__name__)
31+
32+
_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
33+
_MAX_SEARCH_QUERY_LEN = 500
34+
_VALID_SEARCH_TYPES = frozenset({"all", "chat", "composer"})
35+
36+
37+
def _parse_since_days_param(raw: str | None) -> int | None:
38+
if raw is None or not str(raw).strip():
39+
return None
40+
try:
41+
days = int(raw)
42+
except ValueError:
43+
return None
44+
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
45+
return None
46+
return days
47+
48+
49+
def _search_error(
50+
message: str,
51+
code: str,
52+
status: int,
53+
) -> tuple[Response, int]:
54+
return json_response({"error": message, "code": code}, status)
55+
56+
57+
def _workspace_exists(workspace_id: str, workspace_path: str) -> bool:
58+
if workspace_id == "global":
59+
return True
60+
if workspace_id.startswith("cli:"):
61+
project_id = workspace_id[4:]
62+
return any(
63+
cp.get("project_id") == project_id
64+
for cp in list_cli_projects(get_cli_chats_path())
65+
)
66+
return os.path.isdir(os.path.join(workspace_path, workspace_id))
67+
68+
69+
def _filter_results_by_workspace(
70+
results: list[SearchResult],
71+
workspace_id: str,
72+
) -> list[SearchResult]:
73+
return [r for r in results if r.get("workspaceId") == workspace_id]
74+
75+
76+
@bp.route("/api/search")
77+
def search() -> tuple[Response, int] | Response:
78+
"""Search chats, composers, and CLI sessions across Cursor storage.
79+
80+
Args:
81+
q: Search query string (required; 400 when empty).
82+
type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
83+
workspace: Optional workspace folder hash; 404 when unknown.
84+
85+
Returns:
86+
JSON ``{"results": [...]}`` with optional ``warnings``. Structured
87+
``{"error", "code"}`` bodies for 400/404/503/500 failures.
88+
"""
89+
query = request.args.get("q", "").strip()
90+
if not query:
91+
return _search_error("No search query provided", "empty_query", 400)
92+
if len(query) > _MAX_SEARCH_QUERY_LEN:
93+
return _search_error("Search query is too long", "query_too_long", 400)
94+
95+
search_type = request.args.get("type", "all")
96+
if search_type not in _VALID_SEARCH_TYPES:
97+
return _search_error("Invalid search type", "invalid_type", 400)
98+
99+
since_days_raw = request.args.get("since_days")
100+
if (
101+
since_days_raw is not None
102+
and str(since_days_raw).strip()
103+
and _parse_since_days_param(since_days_raw) is None
104+
):
105+
return _search_error("Invalid since_days parameter", "invalid_since_days", 400)
106+
107+
workspace_filter = request.args.get("workspace", "").strip() or None
108+
workspace_path = resolve_workspace_path()
109+
if workspace_filter and not _workspace_exists(workspace_filter, workspace_path):
110+
return _search_error("Workspace not found", "workspace_not_found", 404)
111+
112+
try:
113+
rules = current_app.config.get("EXCLUSION_RULES") or []
114+
all_history = request.args.get("all_history") in ("1", "true")
115+
since_ms = resolve_search_since_ms(
116+
all_history=all_history,
117+
since_days=_parse_since_days_param(since_days_raw),
118+
)
119+
120+
parse_warnings = ParseWarningCollector()
121+
query_lower = query.lower()
122+
123+
results: list[SearchResult] = []
124+
if search_type != "chat":
125+
results.extend(
126+
search_global_storage(
127+
workspace_path,
128+
query,
129+
query_lower,
130+
rules,
131+
parse_warnings,
132+
since_ms=since_ms,
133+
)
134+
)
135+
results.extend(
136+
search_legacy_workspaces(
137+
workspace_path,
138+
query,
139+
query_lower,
140+
search_type,
141+
rules,
142+
since_ms=since_ms,
143+
)
144+
)
145+
if search_type == "all":
146+
results.extend(
147+
search_cli_sessions(
148+
get_cli_chats_path(),
149+
query,
150+
query_lower,
151+
rules,
152+
parse_warnings,
153+
since_ms=since_ms,
154+
)
155+
)
156+
157+
ranked = rank_results(results)
158+
if workspace_filter:
159+
ranked = _filter_results_by_workspace(ranked, workspace_filter)
160+
161+
payload: dict[str, Any] = {
162+
"results": ranked,
163+
"allHistory": since_ms is None,
164+
"searchWindowDays": (
165+
None if since_ms is None else (
166+
_parse_since_days_param(since_days_raw)
167+
or DEFAULT_SEARCH_WINDOW_DAYS
168+
)
169+
),
170+
}
171+
return json_response(parse_warnings.attach_to(payload))
172+
173+
except sqlite3.OperationalError:
174+
_logger.exception("Search index unavailable")
175+
return _search_error(
176+
"Search index is temporarily unavailable",
177+
"search_index_unavailable",
178+
503,
179+
)
180+
except Exception:
181+
_logger.exception("Search failed")
182+
return _search_error("Search failed", "internal_error", 500)
183+

static/css/style.css

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,53 @@ h3 { font-size: 1.15rem; font-weight: 600; }
245245
}
246246
.input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); }
247247

248-
.search-bar { display: flex; gap: 0.5rem; }
248+
.search-bar { display: flex; gap: 0.5rem; align-items: center; }
249249
.search-bar .input { flex: 1; }
250250

251+
.search-info-tooltip {
252+
position: relative;
253+
display: inline-flex;
254+
align-items: center;
255+
justify-content: center;
256+
flex-shrink: 0;
257+
}
258+
.search-info-icon {
259+
display: inline-flex;
260+
align-items: center;
261+
justify-content: center;
262+
width: 1.25rem;
263+
height: 1.25rem;
264+
border-radius: 50%;
265+
border: 1px solid var(--border);
266+
color: var(--text-muted);
267+
font-size: 0.75rem;
268+
font-weight: 700;
269+
font-style: italic;
270+
cursor: help;
271+
user-select: none;
272+
}
273+
.search-info-tooltip-text {
274+
display: none;
275+
position: absolute;
276+
top: calc(100% + 0.5rem);
277+
right: 0;
278+
z-index: 60;
279+
width: min(20rem, 70vw);
280+
padding: 0.75rem 0.875rem;
281+
border: 1px solid var(--info-border);
282+
border-radius: 0.5rem;
283+
background: var(--info-bg);
284+
color: var(--info-text);
285+
font-size: 0.8125rem;
286+
line-height: 1.45;
287+
box-shadow: 0 4px 12px var(--shadow);
288+
}
289+
.search-info-tooltip:hover .search-info-tooltip-text,
290+
.search-info-tooltip:focus .search-info-tooltip-text,
291+
.search-info-tooltip:focus-within .search-info-tooltip-text {
292+
display: block;
293+
}
294+
251295
/* ---------- Alerts ---------- */
252296
.alert {
253297
padding: 0.75rem 1rem;

templates/search.html

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ <h1>Search</h1>
1414
<div class="form-group" style="margin-bottom:1.5rem">
1515
<div class="search-bar">
1616
<input type="text" id="search-input" class="input" placeholder="Search across all logs..." autofocus>
17+
<span class="search-info-tooltip" tabindex="0" aria-label="Search window help">
18+
<span class="search-info-icon" aria-hidden="true">i</span>
19+
<span class="search-info-tooltip-text">
20+
By default, search covers the most recent 30-day indexed window.
21+
Chats without a parseable date are always included.
22+
Check &ldquo;Include chats older than 30 days&rdquo; to search full history.
23+
</span>
24+
</span>
1725
<button class="btn btn-primary" onclick="doSearch()">Search</button>
1826
</div>
1927
<label class="text-sm" style="display:flex;align-items:center;gap:0.5rem;margin-top:0.75rem;cursor:pointer">
@@ -94,13 +102,14 @@ <h1>Search</h1>
94102
const params = new URLSearchParams({ q: query, type: type });
95103
if (allHistory) params.set('all_history', '1');
96104
const res = await fetch(`/api/search?${params.toString()}`);
105+
const data = await res.json().catch(() => ({}));
97106
if (!res.ok) {
98107
showIncompleteResultsBanner('parse-warnings-host', []);
99108
loading.style.display = 'none';
100-
container.innerHTML = '<p class="text-danger">Search failed.</p>';
109+
const message = typeof data.error === 'string' ? data.error : 'Search failed.';
110+
container.innerHTML = `<p class="text-danger">${escapeHtml(message)}</p>`;
101111
return;
102112
}
103-
const data = await res.json();
104113
const results = data.results || [];
105114
showIncompleteResultsBanner('parse-warnings-host', data.warnings);
106115

0 commit comments

Comments
 (0)