How I Recovered my "Vanished Cursor Chat" - so you dont have to

How I Recovered a Vanished Cursor Agent Chat

What happened

A long-running agent conversation (~2,700 messages over multiple days) vanished from the Cursor sidebar mid-session, while I was using it. One moment it was there, the next it was gone. Restarting Cursor didn’t bring it back.

What we discovered

After several hours of investigation, we found that Cursor stores conversations across two separate SQLite databases, and the sidebar is controlled by an index in the workspace-specific one — not the global one where the actual conversation data lives.

The two databases

  1. Global DB: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb

    • Stores actual conversation content: composerData, bubbleId entries, checkpointId entries in the cursorDiskKV table
    • This is the big one (ours was ~59GB)
  2. Workspace DB: ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb

    • Stores a sidebar index in an ItemTable key called composer.composerData
    • This is small (~100MB)

The sidebar index

The workspace DB’s composer.composerData key contains JSON with:

  • allComposers: array of {type:"head", composerId:"<uuid>", name:"...", ...} objects — this is what populates the sidebar list
  • selectedComposerIds: array of UUIDs for currently open tabs

What went wrong in our case

Our conversation’s entry was removed from allComposers in the workspace DB. The full conversation data (2,709 messages, all tool calls, file edits, everything) was still intact in the global DB. But without being in allComposers, Cursor never even tried to load it.

We also found that our conversation’s composerData in the global DB had status: "aborted" and fullConversationHeadersOnly: [] (empty bubble list), so we had to fix those too.

How we fixed it

Your situation may differ from ours. These are the steps we went through — adapt as needed.

Step 1: Back up both databases

Do this before touching anything.

# Back up global DB (this is large - 50+ GB, takes a few minutes)
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb /path/to/backup/state.vscdb.backup
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb-wal /path/to/backup/state.vscdb-wal.backup
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb-shm /path/to/backup/state.vscdb-shm.backup

Step 2: Find your workspace hash

Each project has its own workspace storage directory. Find yours:

for dir in ~/Library/Application\ Support/Cursor/User/workspaceStorage/*/; do
  if [ -f "$dir/workspace.json" ]; then
    if grep -q "YOUR_PROJECT_FOLDER_NAME" "$dir/workspace.json" 2>/dev/null; then
      echo "FOUND: $dir"
      cat "$dir/workspace.json"
    fi
  fi
done

Step 3: Find your lost conversation ID

The conversation data likely still exists in the global DB. This script lists all conversations:

# find_lost_conversation.py
import sqlite3, json, os
from datetime import datetime

global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')

conn = sqlite3.connect(global_db)
c = conn.cursor()

# List all conversations sorted by most recent
c.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
convos = []
for key, val in c.fetchall():
    try:
        obj = json.loads(val)
        convos.append({
            'id': key.replace('composerData:', ''),
            'name': obj.get('name', '(unnamed)'),
            'lastUpdatedAt': obj.get('lastUpdatedAt', 0),
            'status': obj.get('status', '?'),
            'bubbles': len(obj.get('fullConversationHeadersOnly', [])),
        })
    except:
        pass

convos.sort(key=lambda x: x['lastUpdatedAt'], reverse=True)
for co in convos[:30]:
    ts = datetime.fromtimestamp(co['lastUpdatedAt']/1000).strftime('%Y-%m-%d %H:%M') if co['lastUpdatedAt'] else '?'
    print(f"{ts} | {co['status']:10s} | {co['bubbles']:5d} bubbles | {co['name'][:60]}")
    print(f"  ID: {co['id']}")

conn.close()

Note: This query can be slow on a large database. Look for your conversation by name, date, or bubble count.

Step 4: Verify the conversation data exists

# verify_conversation.py
import sqlite3, json, os

CONV_ID = 'YOUR_CONVERSATION_ID_HERE'  # <-- Replace this
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')

conn = sqlite3.connect(global_db)
c = conn.cursor()

c.execute("SELECT length(value) FROM cursorDiskKV WHERE key = ?", (f'composerData:{CONV_ID}',))
r = c.fetchone()
print(f"composerData: {r[0] if r else 'MISSING'} bytes")

c.execute("SELECT COUNT(*) FROM cursorDiskKV WHERE key LIKE ?", (f'bubbleId:{CONV_ID}:%',))
print(f"bubbleId entries: {c.fetchone()[0]}")

conn.close()

If composerData exists and there are bubbleId entries, your conversation data is intact.

Step 5: Check if the conversation is in the sidebar index

# check_sidebar_index.py
import sqlite3, json, os

CONV_ID = 'YOUR_CONVERSATION_ID_HERE'
WORKSPACE_HASH = 'YOUR_WORKSPACE_HASH'  # From step 2

ws_db = os.path.expanduser(f'~/Library/Application Support/Cursor/User/workspaceStorage/{WORKSPACE_HASH}/state.vscdb')

conn = sqlite3.connect(ws_db)
c = conn.cursor()
c.execute('SELECT value FROM ItemTable WHERE key = ?', ('composer.composerData',))
obj = json.loads(c.fetchone()[0])

all_composers = obj.get('allComposers', [])
found = any(isinstance(e, dict) and e.get('composerId') == CONV_ID for e in all_composers)
print(f"allComposers has {len(all_composers)} entries")
print(f"Your conversation is in the list: {found}")
conn.close()

If your conversation is NOT in the list, that’s why it vanished. Proceed to step 6.

Step 6: Add the conversation to the sidebar index

Close Cursor completely first (Cmd+Q or pkill -9 -f Cursor). Cursor will revert changes made while it’s running.

# fix_sidebar.py
import sqlite3, json, os

CONV_ID = 'YOUR_CONVERSATION_ID_HERE'  # <-- Replace this
WORKSPACE_HASH = 'YOUR_WORKSPACE_HASH'  # <-- From step 2

global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')
ws_db = os.path.expanduser(f'~/Library/Application Support/Cursor/User/workspaceStorage/{WORKSPACE_HASH}/state.vscdb')

# Read conversation metadata from global DB
gconn = sqlite3.connect(global_db)
gc = gconn.cursor()
gc.execute('SELECT value FROM cursorDiskKV WHERE key = ?', (f'composerData:{CONV_ID}',))
row = gc.fetchone()
if not row:
    print("ERROR: composerData not found in global DB")
    exit(1)
conv_data = json.loads(row[0])
gconn.close()

# Build the sidebar head entry
head = {
    "type": "head",
    "composerId": CONV_ID,
    "name": conv_data.get("name", "Recovered conversation"),
    "lastUpdatedAt": conv_data.get("lastUpdatedAt", 0),
    "createdAt": conv_data.get("createdAt", 0),
    "unifiedMode": conv_data.get("unifiedMode", "agent"),
    "forceMode": conv_data.get("forceMode", "edit"),
    "hasUnreadMessages": False,
    "contextUsagePercent": conv_data.get("contextUsagePercent", 0),
    "totalLinesAdded": conv_data.get("totalLinesAdded", 0),
    "totalLinesRemoved": conv_data.get("totalLinesRemoved", 0),
    "filesChangedCount": conv_data.get("filesChangedCount", 0),
    "subtitle": conv_data.get("subtitle", ""),
    "isAgentic": conv_data.get("isAgentic", True),
    "status": conv_data.get("status", "completed"),
}

# Add to workspace sidebar index
conn = sqlite3.connect(ws_db)
c = conn.cursor()
c.execute('SELECT value FROM ItemTable WHERE key = ?', ('composer.composerData',))
ws_data = json.loads(c.fetchone()[0])

all_composers = ws_data.get('allComposers', [])
already_present = any(
    isinstance(e, dict) and e.get('composerId') == CONV_ID 
    for e in all_composers
)

if already_present:
    print("Conversation already in sidebar index")
else:
    all_composers.insert(0, head)
    ws_data['allComposers'] = all_composers
    
    # Also add to selectedComposerIds (open tabs)
    selected = ws_data.get('selectedComposerIds', [])
    if CONV_ID not in selected:
        selected.insert(0, CONV_ID)
        ws_data['selectedComposerIds'] = selected
    
    # Write as compact JSON (no spaces) to match Cursor's format
    c.execute('UPDATE ItemTable SET value = ? WHERE key = ?',
        (json.dumps(ws_data, separators=(',', ':')), 'composer.composerData'))
    conn.commit()
    print(f"SUCCESS: Added to allComposers ({len(all_composers)} total)")
    print(f"Added to selectedComposerIds ({len(selected)} total)")

conn.close()
print("\nReopen Cursor. The conversation should appear in the sidebar.")

Step 7 (if needed): Fix empty bubble headers

In our case, the conversation’s composerData in the global DB had fullConversationHeadersOnly: [] (empty) and status: "aborted". The conversation appeared in the sidebar after step 6 but showed no messages until we fixed this.

If you have the same issue, run this with Cursor closed:

# fix_bubble_headers.py
import sqlite3, json, os

CONV_ID = 'YOUR_CONVERSATION_ID_HERE'
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')

conn = sqlite3.connect(global_db)
c = conn.cursor()

# Get all bubble entries
c.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE ?", (f'bubbleId:{CONV_ID}:%',))
rows = c.fetchall()

bubbles = []
for key, value in rows:
    try:
        obj = json.loads(value)
        bubbles.append({
            'bubbleId': obj.get('bubbleId', key.split(':')[-1]),
            'type': obj.get('type', 2),
            'createdAt': obj.get('createdAt', ''),
        })
    except:
        pass

bubbles.sort(key=lambda b: b['createdAt'])
headers = [{'bubbleId': b['bubbleId'], 'type': b['type']} for b in bubbles]

# Update composerData
c.execute('SELECT value FROM cursorDiskKV WHERE key = ?', (f'composerData:{CONV_ID}',))
obj = json.loads(c.fetchone()[0])
obj['fullConversationHeadersOnly'] = headers
obj['status'] = 'completed'  # Was 'aborted' in our case

# Write as compact JSON to match Cursor's format
c.execute('UPDATE cursorDiskKV SET value = ? WHERE key = ?',
    (json.dumps(obj, separators=(',', ':')), f'composerData:{CONV_ID}'))
conn.commit()
print(f"Updated: {len(headers)} bubble headers, status=completed")
conn.close()

Important notes from our experience

  • Always back up before modifying anything. We backed up all three DB files (.vscdb, -wal, -shm) to an external drive before starting.
  • Close Cursor completely before writing to the databases. Cursor reverts changes made while it’s running. We learned this the hard way — multiple fixes were silently undone on restart.
  • Use compact JSON (separators=(',', ':')) when writing. Cursor’s internal serialization uses no spaces. We initially wrote with Python’s default spacing and while it shouldn’t matter for a JSON parser, matching the format avoids any potential issues.
  • The global DB can be 50+ GB. LIKE queries on cursorDiskKV are extremely slow. Target specific keys when possible.
  • Cursor actively garbage-collects conversation data. We observed Cursor delete a 28MB composerData entry in real-time while investigating. Don’t wait — back up immediately if a conversation vanishes.

Architecture reference

This is what we mapped out during our investigation. Your Cursor version may differ.

Cursor conversation storage (as of v2.4.28, Feb 2026):

Global DB (globalStorage/state.vscdb):
  cursorDiskKV table:
    composerData:<conv-id>      → full conversation metadata + bubble header list
    bubbleId:<conv-id>:<id>     → individual message content (JSON)
    checkpointId:<conv-id>:<id> → file state snapshots per message
    agentKv:checkpoint:<conv-id> → Merkle tree root for LLM context
    agentKv:blob:<sha256>        → content-addressable blob storage

Workspace DB (workspaceStorage/<hash>/state.vscdb):
  ItemTable:
    composer.composerData → JSON with:
      allComposers: [{type:"head", composerId:"...", name:"...", ...}, ...]
        ↑ THIS IS THE SIDEBAR INDEX
      selectedComposerIds: ["uuid1", "uuid2", ...]  ← open tabs
      lastFocusedComposerIds: [...]

The sidebar reads ONLY from allComposers in the workspace DB. If a conversation is missing from that list, it’s invisible — regardless of whether the full data exists in the global DB. That was our problem, and adding the entry back to allComposers was the fix.