Skip to content

Commit 33db2e0

Browse files
committed
initial implementation
1 parent 33d0478 commit 33db2e0

15 files changed

Lines changed: 451 additions & 17 deletions

api/search.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from utils.path_helpers import to_epoch_ms, warn_workspace_json_read
2020
from utils.text_extract import extract_text_from_bubble
2121
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
22-
from models import Bubble, Composer, SchemaError
22+
from models import Bubble, Composer, ParseWarningCollector, SchemaError
2323

2424
bp = Blueprint("search", __name__)
2525
_logger = logging.getLogger(__name__)
@@ -79,6 +79,7 @@ def search():
7979

8080
workspace_path = resolve_workspace_path()
8181
results = []
82+
parse_warnings = ParseWarningCollector()
8283
query_lower = query.lower()
8384

8485
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
@@ -170,8 +171,9 @@ def search():
170171
e,
171172
type(e).__name__,
172173
)
174+
parse_warnings.record_bubble_skipped()
173175
except (json.JSONDecodeError, ValueError):
174-
pass
176+
parse_warnings.record_bubble_skipped()
175177

176178
# Search through composerData
177179
composer_rows = conn.execute(
@@ -189,8 +191,10 @@ def search():
189191
e,
190192
type(e).__name__,
191193
)
194+
parse_warnings.record_composer_skipped()
192195
continue
193196
except (json.JSONDecodeError, TypeError, ValueError):
197+
parse_warnings.record_composer_skipped()
194198
continue
195199
try:
196200
cd = composer.raw
@@ -285,6 +289,7 @@ def search():
285289
composer_id,
286290
e,
287291
)
292+
parse_warnings.record_composer_skipped()
288293

289294
except Exception:
290295
_logger.exception("Error searching global storage")
@@ -498,7 +503,8 @@ def _ts(r):
498503
return t
499504
results.sort(key=_ts, reverse=True)
500505

501-
return jsonify({"results": results})
506+
payload: dict = {"results": results}
507+
return jsonify(parse_warnings.attach_to(payload))
502508

503509
except Exception:
504510
_logger.exception("Search failed")

api/workspaces.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def list_workspaces():
5555
try:
5656
workspace_path = resolve_workspace_path()
5757
rules = current_app.config.get("EXCLUSION_RULES") or []
58-
projects = list_workspace_projects(workspace_path, rules)
58+
projects, warnings = list_workspace_projects(workspace_path, rules)
59+
if warnings:
60+
return jsonify({"projects": projects, "warnings": warnings})
5961
return jsonify(projects)
6062
except Exception:
6163
_logger.exception("Failed to get workspaces")

models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from models.cli_session import CliSessionMeta
22
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
33
from models.errors import SchemaError
4+
from models.parse_warnings import ParseWarningCollector, attach_warnings
45
from models.export import ExportEntry
56
from models.workspace import Workspace
67

@@ -9,7 +10,9 @@
910
"CliSessionMeta",
1011
"Composer",
1112
"ExportEntry",
13+
"ParseWarningCollector",
1214
"SchemaError",
1315
"Workspace",
1416
"WorkspaceLocalComposer",
17+
"attach_warnings",
1518
]

models/parse_warnings.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class ParseWarningCollector:
8+
"""Accumulates parse failures skipped during bubble/composer processing."""
9+
10+
composers_skipped: int = 0
11+
bubbles_skipped: int = 0
12+
13+
def record_composer_skipped(self, count: int = 1) -> None:
14+
if count > 0:
15+
self.composers_skipped += count
16+
17+
def record_bubble_skipped(self, count: int = 1) -> None:
18+
if count > 0:
19+
self.bubbles_skipped += count
20+
21+
@property
22+
def has_warnings(self) -> bool:
23+
return self.composers_skipped > 0 or self.bubbles_skipped > 0
24+
25+
def to_api_list(self) -> list[dict]:
26+
"""Structured warnings for JSON API responses (issue #67)."""
27+
warnings: list[dict] = []
28+
if self.composers_skipped:
29+
n = self.composers_skipped
30+
noun = "conversation" if n == 1 else "conversations"
31+
warnings.append({
32+
"type": "parse_error",
33+
"count": n,
34+
"detail": (
35+
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
36+
),
37+
})
38+
if self.bubbles_skipped:
39+
n = self.bubbles_skipped
40+
noun = "message" if n == 1 else "messages"
41+
warnings.append({
42+
"type": "parse_error",
43+
"count": n,
44+
"detail": (
45+
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
46+
),
47+
})
48+
return warnings
49+
50+
def attach_to(self, payload: dict) -> dict:
51+
"""Add ``warnings`` to a dict response when any failures were recorded."""
52+
if self.has_warnings:
53+
payload = {**payload, "warnings": self.to_api_list()}
54+
return payload
55+
56+
57+
def attach_warnings(payload: dict, warnings: list[dict]) -> dict:
58+
"""Merge pre-built warnings into a response dict."""
59+
if warnings:
60+
return {**payload, "warnings": warnings}
61+
return payload

services/workspace_listing.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from utils.workspace_descriptor import read_json_file
2020
from utils.workspace_path import get_cli_chats_path
21-
from models import Composer, SchemaError
21+
from models import Composer, ParseWarningCollector, SchemaError
2222
from services.workspace_db import (
2323
_build_composer_id_to_workspace_id,
2424
_collect_invalid_workspace_ids,
@@ -37,8 +37,9 @@
3737
)
3838

3939

40-
def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
41-
"""Return the sorted project list that GET /api/workspaces renders."""
40+
def list_workspace_projects(workspace_path: str, rules: list) -> tuple[list[dict], list[dict]]:
41+
"""Return (projects, warnings) for GET /api/workspaces."""
42+
parse_warnings = ParseWarningCollector()
4243
workspace_entries = _collect_workspace_entries(workspace_path)
4344
invalid_workspace_ids = _collect_invalid_workspace_ids(workspace_entries)
4445

@@ -84,13 +85,15 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
8485
cid,
8586
e,
8687
)
88+
parse_warnings.record_composer_skipped()
8789
continue
8890
if not isinstance(parsed, dict):
8991
_logger.warning(
9092
"Failed to parse Composer from composerData:%s: expected object, got %s",
9193
cid,
9294
type(parsed).__name__,
9395
)
96+
parse_warnings.record_composer_skipped()
9497
continue
9598
try:
9699
composer = Composer.from_dict(parsed, composer_id=cid)
@@ -100,6 +103,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
100103
cid,
101104
e,
102105
)
106+
parse_warnings.record_composer_skipped()
103107
continue
104108
cd = composer.raw
105109
try:
@@ -134,6 +138,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
134138
cid,
135139
e,
136140
)
141+
parse_warnings.record_composer_skipped()
137142
except Exception as e:
138143
_logger.error(
139144
"Failed to load composer rows from global storage: %s",
@@ -284,4 +289,4 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
284289
_logger.warning("Failed to load CLI projects: %s", e)
285290

286291
projects.sort(key=lambda p: p["lastModified"], reverse=True)
287-
return projects
292+
return projects, parse_warnings.to_api_list()

services/workspace_tabs.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from utils.text_extract import extract_text_from_bubble
2121
from utils.tool_parser import parse_tool_call as _parse_tool_call
2222
from utils.workspace_descriptor import read_json_file
23-
from models import Bubble, Composer, SchemaError
23+
from models import Bubble, Composer, ParseWarningCollector, SchemaError
2424
from services.workspace_db import (
2525
_build_composer_id_to_workspace_id,
2626
_collect_invalid_workspace_ids,
@@ -83,6 +83,7 @@ def assemble_workspace_tabs(
8383
rules: list,
8484
) -> tuple[dict, int]:
8585
"""Build (payload, status) for GET /api/workspaces/<id>/tabs; status=404 if global storage is missing."""
86+
parse_warnings = ParseWarningCollector()
8687
response: dict = {"tabs": []}
8788

8889
workspace_entries = _collect_workspace_entries(workspace_path)
@@ -142,6 +143,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
142143
"Skipping Bubble cursorDiskKV row with NULL value: key=%r",
143144
row["key"],
144145
)
146+
parse_warnings.record_bubble_skipped()
145147
continue
146148
try:
147149
parsed = json.loads(row["value"])
@@ -155,6 +157,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
155157
payload_len,
156158
payload_fp,
157159
)
160+
parse_warnings.record_bubble_skipped()
158161
continue
159162
try:
160163
bubble_obj = Bubble.from_dict(parsed, bubble_id=bid)
@@ -168,6 +171,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
168171
bid,
169172
e,
170173
)
174+
parse_warnings.record_bubble_skipped()
171175

172176
# Load codeBlockDiffs
173177
code_block_diff_map = load_code_block_diff_map(global_db)
@@ -232,6 +236,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
232236
"Skipping Composer cursorDiskKV row with NULL value: key=%r",
233237
row["key"],
234238
)
239+
parse_warnings.record_composer_skipped()
235240
continue
236241
try:
237242
parsed = json.loads(row["value"])
@@ -245,6 +250,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
245250
payload_len,
246251
payload_fp,
247252
)
253+
parse_warnings.record_composer_skipped()
248254
continue
249255
try:
250256
composer = Composer.from_dict(parsed, composer_id=composer_id)
@@ -257,6 +263,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
257263
composer_id,
258264
e,
259265
)
266+
parse_warnings.record_composer_skipped()
260267
continue
261268
try:
262269
cd = composer.raw
@@ -579,8 +586,9 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
579586
composer_id,
580587
e,
581588
)
589+
parse_warnings.record_composer_skipped()
582590

583591
# Sort tabs by timestamp descending (newest first)
584592
response["tabs"].sort(key=lambda t: t.get("timestamp") or 0, reverse=True)
585593

586-
return response, 200
594+
return parse_warnings.attach_to(response), 200

static/css/style.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
--success-bg: #052e16;
3737
--success-border: #166534;
3838
--danger-bg: #450a0a;
39+
--warning-bg: #422006;
40+
--warning-border: #854d0e;
41+
--warning-text: #fcd34d;
3942
--danger-border: #991b1b;
4043
--code-bg: #1e1e1e;
4144
--spinner: #3b82f6;
@@ -74,6 +77,9 @@
7477
--success-bg: #f0fdf4;
7578
--success-border: #bbf7d0;
7679
--danger-bg: #fef2f2;
80+
--warning-bg: #fffbeb;
81+
--warning-border: #fcd34d;
82+
--warning-text: #92400e;
7783
--danger-border: #fecaca;
7884
--code-bg: #f5f5f5;
7985
--spinner: #2563eb;
@@ -249,6 +255,7 @@ h3 { font-size: 1.15rem; font-weight: 600; }
249255
.alert-info { background: var(--info-bg); border: 1px solid var(--info-border); color: var(--info-text); }
250256
.alert-success { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success); }
251257
.alert-danger { background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); }
258+
.alert-warning { background: var(--warning-bg); border: 1px solid var(--warning-border); color: var(--warning-text); }
252259

253260
/* ---------- Badges ---------- */
254261
.badge {

static/js/app.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,48 @@ function formatDate(timestamp) {
8686
function sanitizeFilename(name) {
8787
return (name || '').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 120);
8888
}
89+
90+
/**
91+
* Normalize GET /api/workspaces body: plain array (no warnings) or
92+
* { projects, warnings } when parse failures occurred (issue #67).
93+
*/
94+
function normalizeWorkspacesResponse(body) {
95+
if (Array.isArray(body)) {
96+
return { projects: body, warnings: [] };
97+
}
98+
if (body && typeof body === 'object') {
99+
return {
100+
projects: body.projects || [],
101+
warnings: body.warnings || [],
102+
};
103+
}
104+
return { projects: [], warnings: [] };
105+
}
106+
107+
/** Human-readable text from API ``warnings`` entries. */
108+
function formatParseWarnings(warnings) {
109+
if (!warnings || !warnings.length) return '';
110+
return warnings.map(w => w.detail || `${w.count || 0} items could not be loaded`).join(' ');
111+
}
112+
113+
/**
114+
* Show or replace an incomplete-results banner (issue #67).
115+
* @param {string} containerId - element to prepend into
116+
* @param {Array} warnings - API warnings array
117+
*/
118+
function showIncompleteResultsBanner(containerId, warnings) {
119+
const container = document.getElementById(containerId);
120+
if (!container || !warnings || !warnings.length) return;
121+
122+
const existing = container.querySelector('.incomplete-results-banner');
123+
if (existing) existing.remove();
124+
125+
const text = formatParseWarnings(warnings);
126+
const banner = document.createElement('div');
127+
banner.className = 'alert alert-warning incomplete-results-banner';
128+
banner.setAttribute('role', 'status');
129+
banner.textContent = text
130+
? `Some results may be incomplete: ${text}`
131+
: 'Some results may be incomplete due to parse errors.';
132+
container.insertBefore(banner, container.firstChild);
133+
}

templates/index.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ <h1>Projects</h1>
2626
<p>Loading projects...</p>
2727
</div>
2828

29+
<div id="parse-warnings-host"></div>
2930
<div id="projects-container" style="display:none"></div>
3031

3132
<script>
@@ -36,7 +37,9 @@ <h1>Projects</h1>
3637
fetch('/api/workspaces'),
3738
fetch('/api/export/state')
3839
]);
39-
const projects = await projRes.json();
40+
const body = await projRes.json();
41+
const { projects, warnings } = normalizeWorkspacesResponse(body);
42+
showIncompleteResultsBanner('parse-warnings-host', warnings);
4043
renderProjects(projects);
4144

4245
// Show last export time

templates/search.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ <h1>Search</h1>
2323
<p>Searching...</p>
2424
</div>
2525

26+
<div id="parse-warnings-host"></div>
2627
<p id="result-count" class="text-muted" style="display:none"></p>
2728
<div id="results-container"></div>
2829
</div>
@@ -81,6 +82,7 @@ <h1>Search</h1>
8182
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${type}`);
8283
const data = await res.json();
8384
const results = data.results || [];
85+
showIncompleteResultsBanner('parse-warnings-host', data.warnings);
8486

8587
loading.style.display = 'none';
8688
countEl.style.display = 'block';

0 commit comments

Comments
 (0)