Skip to content

Commit 7058507

Browse files
committed
feat: initial implementation for lazy and dynamic loading
1 parent 3bd7ce0 commit 7058507

8 files changed

Lines changed: 1221 additions & 355 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Lazy-load workspace UI** — workspace sidebar renders from a lightweight summary
12+
payload; full bubble content is fetched per-conversation when the user selects it,
13+
reducing first-paint time from 1–2 min to < 3 s on large local fixtures (#84)
14+
- **`GET /api/workspaces/<id>/tabs?summary=1`** — new summary-only variant returns
15+
`id`, `title`, `timestamp`, `messageCount`, and optional `metadata.modelsUsed`
16+
without loading any bubble data (#84)
17+
- **`GET /api/workspaces/<id>/tabs/<composer_id>`** — new single-conversation
18+
endpoint loads only scoped `bubbleId:{id}:%`, `messageRequestContext:{id}:%`,
19+
and `codeBlockDiff:{id}:%` KV rows, avoiding a full global bubble scan (#84)
20+
- **Scoped KV loaders** in `services/workspace_db.py`:
21+
`load_bubbles_for_composer`, `load_message_request_context_for_composer`,
22+
`load_code_block_diffs_for_composer` — used by the single-tab path (#84)
23+
24+
### Changed
25+
- **`GET /api/workspaces`** (`list_workspace_projects`) no longer performs a
26+
global `bubbleId:%` scan; conversation presence is determined from
27+
`fullConversationHeadersOnly` headers alone, and workspace assignment relies
28+
on `composer_id_to_ws` (primary) plus `projectLayouts` from MRC (#84)
29+
- **`assemble_workspace_tabs`** inner per-composer loop refactored into a shared
30+
`_assemble_tab_from_composer_data` helper reused by `assemble_single_tab`; full
31+
path behaviour is unchanged (#84)
32+
33+
### Deprecated
34+
- Direct use of `GET /api/workspaces/<id>/tabs` (no `?summary=1`) from the workspace
35+
UI on page load; the UI now calls `?summary=1` for first paint and lazy-fetches
36+
individual tabs. The full-assembly endpoint remains available for export,
37+
search, and backward-compatible consumers (planned removal: post-1.0) (#84)
38+
39+
1140
- **Web UI** — browse and search all Cursor AI workspaces; conversation view with syntax-highlighted code blocks, dark/light mode, and bookmarkable chat URLs (#63)
1241
- **Export formats** — one-click export of chats as Markdown, HTML, PDF, JSON, and CSV from the web UI (#63)
1342
- **CLI export** (`cursor-chat-export` / `scripts/export.py`) — zip archive or individual Markdown files with YAML frontmatter; incremental mode (`--since last`) preserves state across runs (#63, #42, #61)

api/workspaces.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import os
1212
from datetime import datetime, timezone
1313

14-
from flask import Blueprint, jsonify
14+
from flask import Blueprint, jsonify, request
1515

1616
from api.flask_config import exclusion_rules
1717

@@ -29,7 +29,11 @@
2929
)
3030
from services.cli_tabs import get_cli_workspace_tabs
3131
from services.workspace_listing import list_workspace_projects
32-
from services.workspace_tabs import assemble_workspace_tabs
32+
from services.workspace_tabs import (
33+
assemble_single_tab,
34+
assemble_workspace_tabs,
35+
list_workspace_tab_summaries,
36+
)
3337

3438
# Re-exported for tests/test_models_wired_at_read_sites.py — the typed-model
3539
# spy harness patches `workspaces_mod.Bubble` / `.Composer` / `.Workspace` to
@@ -154,9 +158,31 @@ def get_workspace_tabs(workspace_id):
154158
try:
155159
workspace_path = resolve_workspace_path()
156160
rules = exclusion_rules()
157-
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
161+
summary = request.args.get("summary") in ("1", "true")
162+
if summary:
163+
payload, status = list_workspace_tab_summaries(workspace_id, workspace_path, rules)
164+
else:
165+
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
158166
return jsonify(payload), status
159167
except Exception:
160168
_logger.exception("Failed to get workspace tabs")
161169
return jsonify({"error": "Failed to get workspace tabs"}), 500
162170

171+
172+
# ---------------------------------------------------------------------------
173+
# GET /api/workspaces/<id>/tabs/<composer_id>
174+
# ---------------------------------------------------------------------------
175+
176+
@bp.route("/api/workspaces/<workspace_id>/tabs/<composer_id>")
177+
def get_workspace_tab(workspace_id, composer_id):
178+
if workspace_id.startswith("cli:"):
179+
return jsonify({"error": "Per-tab lazy load is not supported for CLI workspaces"}), 400
180+
try:
181+
workspace_path = resolve_workspace_path()
182+
rules = exclusion_rules()
183+
payload, status = assemble_single_tab(workspace_id, composer_id, workspace_path, rules)
184+
return jsonify(payload), status
185+
except Exception:
186+
_logger.exception("Failed to get workspace tab")
187+
return jsonify({"error": "Failed to get workspace tab"}), 500
188+

services/workspace_db.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,99 @@ def load_code_block_diff_map(global_db) -> dict[str, list]:
113113
return diff_map
114114

115115

116+
def load_bubbles_for_composer(global_db, composer_id: str) -> dict[str, dict]:
117+
"""Load ``bubbleId:{composer_id}:*`` KV entries into ``{bubble_id: bubble_dict}``.
118+
119+
Scoped alternative to :func:`load_bubble_map` for single-conversation assembly;
120+
avoids a full global ``bubbleId:%`` scan.
121+
"""
122+
bubble_map: dict[str, dict] = {}
123+
try:
124+
rows = global_db.execute(
125+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE ?",
126+
(f"bubbleId:{composer_id}:%",),
127+
).fetchall()
128+
except sqlite3.Error:
129+
return bubble_map
130+
for row in rows:
131+
parts = row["key"].split(":")
132+
if len(parts) < 3:
133+
continue
134+
bid = parts[2]
135+
try:
136+
b = json.loads(row["value"])
137+
if isinstance(b, dict):
138+
bubble_map[bid] = b
139+
except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e:
140+
_logger.debug("Skipping malformed bubbleId row %s: %s", row["key"], e)
141+
return bubble_map
142+
143+
144+
def load_message_request_context_for_composer(
145+
global_db, composer_id: str
146+
) -> list[dict]:
147+
"""Load ``messageRequestContext:{composer_id}:*`` KV entries.
148+
149+
Returns a list of context dicts, each with an injected ``contextId`` key
150+
taken from the third path component of the KV key. Scoped alternative to
151+
the global MRC pass inside :func:`load_project_layouts_map`.
152+
"""
153+
contexts: list[dict] = []
154+
try:
155+
rows = global_db.execute(
156+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE ?",
157+
(f"messageRequestContext:{composer_id}:%",),
158+
).fetchall()
159+
except sqlite3.Error:
160+
return contexts
161+
for row in rows:
162+
parts = row["key"].split(":")
163+
if len(parts) < 3:
164+
continue
165+
context_id = parts[2]
166+
try:
167+
ctx = json.loads(row["value"])
168+
if isinstance(ctx, dict):
169+
contexts.append({**ctx, "contextId": context_id})
170+
except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e:
171+
_logger.debug(
172+
"Skipping malformed messageRequestContext row %s: %s",
173+
row["key"],
174+
e,
175+
)
176+
return contexts
177+
178+
179+
def load_code_block_diffs_for_composer(
180+
global_db, composer_id: str
181+
) -> list[dict]:
182+
"""Load ``codeBlockDiff:{composer_id}:*`` KV entries.
183+
184+
Returns a list of diff dicts, each with an injected ``diffId`` key.
185+
Scoped alternative to :func:`load_code_block_diff_map` for single-conversation
186+
assembly.
187+
"""
188+
diffs: list[dict] = []
189+
try:
190+
rows = global_db.execute(
191+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE ?",
192+
(f"codeBlockDiff:{composer_id}:%",),
193+
).fetchall()
194+
except sqlite3.Error:
195+
return diffs
196+
for row in rows:
197+
parts = row["key"].split(":")
198+
try:
199+
d = json.loads(row["value"])
200+
if isinstance(d, dict):
201+
diffs.append({**d, "diffId": parts[2] if len(parts) > 2 else None})
202+
except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e:
203+
_logger.debug(
204+
"Skipping malformed codeBlockDiff row %s: %s", row["key"], e
205+
)
206+
return diffs
207+
208+
116209
def collect_workspace_entries(workspace_path: str) -> list[dict]:
117210
"""Scan workspace directory and return entries with workspace.json.
118211

services/workspace_listing.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
build_composer_id_to_workspace_id,
2424
collect_invalid_workspace_ids,
2525
collect_workspace_entries,
26-
load_bubble_map,
2726
load_project_layouts_map,
2827
open_global_db,
2928
)
@@ -76,7 +75,11 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
7675
)
7776

7877
project_layouts_map: dict[str, list] = load_project_layouts_map(global_db)
79-
bubble_map: dict[str, dict] = load_bubble_map(global_db)
78+
# Summary path: skip the global bubbleId scan entirely.
79+
# Workspace assignment uses composer_id_to_ws (primary) and
80+
# projectLayouts from MRC; bubble fallbacks are reserved for
81+
# full-assembly paths (assemble_workspace_tabs, export, search).
82+
bubble_map: dict[str, dict] = {}
8083

8184
invalid_workspace_aliases = infer_invalid_workspace_aliases(
8285
composer_rows=composer_rows,
@@ -131,14 +134,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
131134
assigned = pid if pid else "global"
132135

133136
headers = cd.get("fullConversationHeadersOnly") or []
134-
has_bubbles = any(
135-
bubble_map.get(bubble_id)
136-
for h in headers
137-
if isinstance(h, dict)
138-
for bubble_id in [h.get("bubbleId")]
139-
if isinstance(bubble_id, str)
140-
)
141-
if not has_bubbles:
137+
if not headers:
142138
continue
143139

144140
conversation_map.setdefault(assigned, []).append({

0 commit comments

Comments
 (0)