Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,4 @@ def export_chats():
type(e).__name__,
exc_info=True,
)
return jsonify({"error": f"Export failed: {str(e)}"}), 500
return jsonify({"error": "Export failed"}), 500
2 changes: 1 addition & 1 deletion api/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def footer(self):
type(e).__name__,
exc_info=True,
)
return jsonify({"error": f"Failed to generate PDF: {str(e)}"}), 500
return jsonify({"error": "Failed to generate PDF"}), 500


def _render_code_block(pdf, code_text: str):
Expand Down
12 changes: 9 additions & 3 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from utils.path_helpers import to_epoch_ms, warn_workspace_json_read
from utils.text_extract import extract_text_from_bubble
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
from models import Bubble, Composer, SchemaError
from models import Bubble, Composer, ParseWarningCollector, SchemaError

bp = Blueprint("search", __name__)
_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -79,6 +79,7 @@ def search():

workspace_path = resolve_workspace_path()
results = []
parse_warnings = ParseWarningCollector()
query_lower = query.lower()

global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
Expand Down Expand Up @@ -170,8 +171,9 @@ def search():
e,
type(e).__name__,
)
parse_warnings.record_bubble_skipped()
except (json.JSONDecodeError, ValueError):
pass
parse_warnings.record_bubble_skipped()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# Search through composerData
composer_rows = conn.execute(
Expand All @@ -189,8 +191,10 @@ def search():
e,
type(e).__name__,
)
parse_warnings.record_composer_skipped()
continue
except (json.JSONDecodeError, TypeError, ValueError):
parse_warnings.record_composer_skipped()
continue
try:
cd = composer.raw
Expand Down Expand Up @@ -285,6 +289,7 @@ def search():
composer_id,
e,
)
parse_warnings.record_composer_skipped()

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

return jsonify({"results": results})
payload: dict = {"results": results}
return jsonify(parse_warnings.attach_to(payload))

except Exception:
_logger.exception("Search failed")
Expand Down
4 changes: 3 additions & 1 deletion api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ def list_workspaces():
try:
workspace_path = resolve_workspace_path()
rules = current_app.config.get("EXCLUSION_RULES") or []
projects = list_workspace_projects(workspace_path, rules)
projects, warnings = list_workspace_projects(workspace_path, rules)
if warnings:
return jsonify({"projects": projects, "warnings": warnings})
return jsonify(projects)
except Exception:
_logger.exception("Failed to get workspaces")
Expand Down
3 changes: 3 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from models.cli_session import CliSessionMeta
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
from models.errors import SchemaError
from models.parse_warnings import ParseWarningCollector, attach_warnings
from models.export import ExportEntry
from models.workspace import Workspace

Expand All @@ -9,7 +10,9 @@
"CliSessionMeta",
"Composer",
"ExportEntry",
"ParseWarningCollector",
"SchemaError",
"Workspace",
"WorkspaceLocalComposer",
"attach_warnings",
]
61 changes: 61 additions & 0 deletions models/parse_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass
class ParseWarningCollector:
"""Accumulates parse failures skipped during bubble/composer processing."""

composers_skipped: int = 0
bubbles_skipped: int = 0

def record_composer_skipped(self, count: int = 1) -> None:
if count > 0:
self.composers_skipped += count

def record_bubble_skipped(self, count: int = 1) -> None:
if count > 0:
self.bubbles_skipped += count

@property
def has_warnings(self) -> bool:
return self.composers_skipped > 0 or self.bubbles_skipped > 0

def to_api_list(self) -> list[dict]:
"""Structured warnings for JSON API responses (issue #67)."""
warnings: list[dict] = []
if self.composers_skipped:
n = self.composers_skipped
noun = "conversation" if n == 1 else "conversations"
warnings.append({
"type": "parse_error",
"count": n,
"detail": (
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
),
})
if self.bubbles_skipped:
n = self.bubbles_skipped
noun = "message" if n == 1 else "messages"
warnings.append({
"type": "parse_error",
"count": n,
"detail": (
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
),
})
return warnings

def attach_to(self, payload: dict) -> dict:
"""Add ``warnings`` to a dict response when any failures were recorded."""
if self.has_warnings:
payload = {**payload, "warnings": self.to_api_list()}
return payload


def attach_warnings(payload: dict, warnings: list[dict]) -> dict:
"""Merge pre-built warnings into a response dict."""
if warnings:
return {**payload, "warnings": warnings}
return payload
13 changes: 9 additions & 4 deletions services/workspace_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
)
from utils.workspace_descriptor import read_json_file
from utils.workspace_path import get_cli_chats_path
from models import Composer, SchemaError
from models import Composer, ParseWarningCollector, SchemaError
from services.workspace_db import (
_build_composer_id_to_workspace_id,
_collect_invalid_workspace_ids,
Expand All @@ -37,8 +37,9 @@
)


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

Expand Down Expand Up @@ -84,13 +85,15 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_skipped()
continue
if not isinstance(parsed, dict):
_logger.warning(
"Failed to parse Composer from composerData:%s: expected object, got %s",
cid,
type(parsed).__name__,
)
parse_warnings.record_composer_skipped()
continue
try:
composer = Composer.from_dict(parsed, composer_id=cid)
Expand All @@ -100,6 +103,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_skipped()
continue
cd = composer.raw
try:
Expand Down Expand Up @@ -134,6 +138,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_skipped()
except Exception as e:
_logger.error(
"Failed to load composer rows from global storage: %s",
Expand Down Expand Up @@ -284,4 +289,4 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
_logger.warning("Failed to load CLI projects: %s", e)

projects.sort(key=lambda p: p["lastModified"], reverse=True)
return projects
return projects, parse_warnings.to_api_list()
12 changes: 10 additions & 2 deletions services/workspace_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from utils.text_extract import extract_text_from_bubble
from utils.tool_parser import parse_tool_call as _parse_tool_call
from utils.workspace_descriptor import read_json_file
from models import Bubble, Composer, SchemaError
from models import Bubble, Composer, ParseWarningCollector, SchemaError
from services.workspace_db import (
_build_composer_id_to_workspace_id,
_collect_invalid_workspace_ids,
Expand Down Expand Up @@ -83,6 +83,7 @@ def assemble_workspace_tabs(
rules: list,
) -> tuple[dict, int]:
"""Build (payload, status) for GET /api/workspaces/<id>/tabs; status=404 if global storage is missing."""
parse_warnings = ParseWarningCollector()
response: dict = {"tabs": []}

workspace_entries = _collect_workspace_entries(workspace_path)
Expand Down Expand Up @@ -142,6 +143,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
"Skipping Bubble cursorDiskKV row with NULL value: key=%r",
row["key"],
)
parse_warnings.record_bubble_skipped()
continue
try:
parsed = json.loads(row["value"])
Expand All @@ -155,6 +157,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
payload_len,
payload_fp,
)
parse_warnings.record_bubble_skipped()
continue
try:
bubble_obj = Bubble.from_dict(parsed, bubble_id=bid)
Expand All @@ -168,6 +171,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
bid,
e,
)
parse_warnings.record_bubble_skipped()

# Load codeBlockDiffs
code_block_diff_map = load_code_block_diff_map(global_db)
Expand Down Expand Up @@ -232,6 +236,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
"Skipping Composer cursorDiskKV row with NULL value: key=%r",
row["key"],
)
parse_warnings.record_composer_skipped()
continue
try:
parsed = json.loads(row["value"])
Expand All @@ -245,6 +250,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
payload_len,
payload_fp,
)
parse_warnings.record_composer_skipped()
continue
try:
composer = Composer.from_dict(parsed, composer_id=composer_id)
Expand All @@ -257,6 +263,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
composer_id,
e,
)
parse_warnings.record_composer_skipped()
continue
try:
cd = composer.raw
Expand Down Expand Up @@ -579,8 +586,9 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
composer_id,
e,
)
parse_warnings.record_composer_skipped()

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

return response, 200
return parse_warnings.attach_to(response), 200
7 changes: 7 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
--success-bg: #052e16;
--success-border: #166534;
--danger-bg: #450a0a;
--warning-bg: #422006;
--warning-border: #854d0e;
--warning-text: #fcd34d;
--danger-border: #991b1b;
--code-bg: #1e1e1e;
--spinner: #3b82f6;
Expand Down Expand Up @@ -74,6 +77,9 @@
--success-bg: #f0fdf4;
--success-border: #bbf7d0;
--danger-bg: #fef2f2;
--warning-bg: #fffbeb;
--warning-border: #fcd34d;
--warning-text: #92400e;
--danger-border: #fecaca;
--code-bg: #f5f5f5;
--spinner: #2563eb;
Expand Down Expand Up @@ -249,6 +255,7 @@ h3 { font-size: 1.15rem; font-weight: 600; }
.alert-info { background: var(--info-bg); border: 1px solid var(--info-border); color: var(--info-text); }
.alert-success { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success); }
.alert-danger { background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); }
.alert-warning { background: var(--warning-bg); border: 1px solid var(--warning-border); color: var(--warning-text); }

/* ---------- Badges ---------- */
.badge {
Expand Down
46 changes: 46 additions & 0 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,49 @@ function formatDate(timestamp) {
function sanitizeFilename(name) {
return (name || '').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 120);
}

/**
* Normalize GET /api/workspaces body: plain array (no warnings) or
* { projects, warnings } when parse failures occurred (issue #67).
*/
function normalizeWorkspacesResponse(body) {
if (Array.isArray(body)) {
return { projects: body, warnings: [] };
}
if (body && typeof body === 'object') {
return {
projects: Array.isArray(body.projects) ? body.projects : [],
warnings: Array.isArray(body.warnings) ? body.warnings : [],
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return { projects: [], warnings: [] };
}

/** Human-readable text from API ``warnings`` entries. */
function formatParseWarnings(warnings) {
if (!warnings || !warnings.length) return '';
return warnings.map(w => w.detail || `${w.count || 0} items could not be loaded`).join(' ');
}

/**
* Show or replace an incomplete-results banner (issue #67).
* @param {string} containerId - element to prepend into
* @param {Array} warnings - API warnings array
*/
function showIncompleteResultsBanner(containerId, warnings) {
const container = document.getElementById(containerId);
if (!container) return;

const existing = container.querySelector('.incomplete-results-banner');
if (existing) existing.remove();
if (!Array.isArray(warnings) || !warnings.length) return;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const text = formatParseWarnings(warnings);
const banner = document.createElement('div');
banner.className = 'alert alert-warning incomplete-results-banner';
banner.setAttribute('role', 'status');
banner.textContent = text
? `Some results may be incomplete: ${text}`
: 'Some results may be incomplete due to parse errors.';
container.insertBefore(banner, container.firstChild);
}
5 changes: 4 additions & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ <h1>Projects</h1>
<p>Loading projects...</p>
</div>

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

<script>
Expand All @@ -36,7 +37,9 @@ <h1>Projects</h1>
fetch('/api/workspaces'),
fetch('/api/export/state')
]);
const projects = await projRes.json();
const body = await projRes.json();
const { projects, warnings } = normalizeWorkspacesResponse(body);
showIncompleteResultsBanner('parse-warnings-host', warnings);
renderProjects(projects);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Show last export time
Expand Down
Loading
Loading