Skip to content

Feature request: make chat sessions server-side so they follow the user across browsers, and let them be archived #7948

Description

@NevermoreN

Summary

Chat session content already lives on the server, but the index that makes
sessions visible
lives in the browser's IndexedDB. The result is that chat
history is silently browser-local: open the same code-server from a second
browser (or a phone) and the chat list is empty, even though every message is
sitting on the server's disk.

I'd like code-server to (1) serve that index from the server so sessions follow
the user, and (2) offer an opt-in archive so sessions survive deletion.

I've been running a working implementation of both for a while — details and
measurements below, in case they're useful for scoping.

Current behaviour

Session content is on the server:

<user-data-dir>/User/workspaceStorage/<workspaceHash>/chatSessions/<uuid>.jsonl

But the list of sessions is stored in the browser:

IndexedDB:  vscode-web-state-db-<workspaceId>
store:      ItemTable
key:        chat.ChatSessionStore.index

Each entry is small metadata — no message content:

{
  "sessionId": "6d8bcdfd-...",
  "title": "",
  "lastMessageDate": 1786645548589,
  "timing": { "created": …, "lastRequestStarted": …, "lastRequestEnded": … },
  "initialLocation": "panel",
  "hasPendingEdits": false,
  "isEmpty": false,
  "isExternal": false,
  "lastResponseState": 1
}

So the index is a pure derivative of files the server already has.

Steps to reproduce

  1. Open code-server in browser A, have a few chats.
  2. Open the same code-server URL in browser B (or a different device).
  3. Browser B shows an empty chat list.
  4. On the server, …/chatSessions/*.jsonl still contains everything.

Why this is worse than it sounds

  • It looks like data loss. Clearing site data, using a private window, or
    switching devices makes the history vanish with no message and no way back
    through the UI.
  • The desktop expectation doesn't hold. In desktop VS Code, chat history is
    tied to the machine you're sitting at. With code-server the "machine" is the
    server, so users reasonably expect history to be there from any browser — and
    it is, just not reachable.
  • It's silent. Nothing tells the user the sessions still exist server-side.

Proposal

1. Serve the chat session index from the server

The server can rebuild that index by scanning chatSessions/*.jsonl — it needs
sessionId, a title, and timestamps, all of which are in the files. The browser
would seed IndexedDB from that endpoint instead of starting empty.

Deletion needs care: if the browser simply merges the server list back in, a
session the user deleted reappears. What works is treating the server as
authoritative for existence: an entry that is gone server-side is removed
locally, and a local entry that has no server file is dropped rather than
resurrected.

2. Opt-in archive

A flag such as --chat-archive <dir> that keeps a copy of each session as it
grows. Two things make it worth more than a plain backup:

  • Sessions get reset in place — VS Code reuses the same sessionId and
    truncates the file. A copier that mirrors the source loses the old content.
    Keeping the longest version seen, and saving post-reset content beside it,
    preserves both.
  • A readable rendering (Markdown) next to the raw .jsonl makes the archive
    greppable and readable without tooling.

Reference implementation

Extracted from a working setup and trimmed to the essentials. My version is
Python (server) + plain JS (browser) because it runs as an injected script;
upstream would presumably do the server half in TypeScript, but the algorithm is
the point.

1. Rebuild the index by replaying the op logs

.jsonl sessions are op logs, not documents. Three op kinds matter, and
skipping any of them silently loses data — kind: 2 in particular is how
streamed responses arrive, so handling only snapshots drops in-flight answers.

def replay(path):
    """Rebuild session state from an op log. Returns the state dict, or None."""
    state = {}
    with open(path, "rt", encoding="utf-8", errors="replace") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                op = json.loads(line)
            except ValueError:
                continue                      # half-written line, still being appended
            kind, key, val = op.get("kind"), op.get("k"), op.get("v")
            if kind == 0:                     # full snapshot
                state = val if isinstance(val, dict) else {}
                continue
            if not isinstance(key, list) or not key:
                continue
            cur = state
            try:
                for seg in key[:-1]:
                    cur = cur[seg]
                last = key[-1]
                if kind == 2:                 # append to array (streamed responses)
                    tgt = cur[last] if (last in cur if isinstance(cur, dict) else True) else None
                    if isinstance(tgt, list) and isinstance(val, list):
                        tgt.extend(val)
                    else:
                        cur[last] = val
                else:                         # kind == 1: set key
                    cur[last] = val
            except (KeyError, IndexError, TypeError):
                continue                      # shape mismatch: skip this op, keep the session
    return state or None


def index_entry(state):
    """One entry of chat.ChatSessionStore.index. Metadata only, no message content."""
    reqs = state.get("requests") or []
    created = state.get("creationDate") or 0
    last = created
    for r in reqs:
        if isinstance(r, dict) and isinstance(r.get("timestamp"), int):
            last = max(last, r["timestamp"])
    return {
        "sessionId": state.get("sessionId"),
        "title": session_title(state),
        "lastMessageDate": last,
        "timing": {"created": created,
                   "lastRequestStarted": last,
                   "lastRequestEnded": last},
        "initialLocation": state.get("initialLocation") or "panel",
        "hasPendingEdits": False,
        "isEmpty": False,
        "isExternal": False,
        "lastResponseState": 1,
    }


def session_title(state):
    """customTitle if the user renamed it, else derive from the first question."""
    t = state.get("customTitle") or state.get("title")
    if t:
        return str(t)[:200]
    for r in state.get("requests") or []:
        text = ((r.get("message") or {}).get("text") or "").strip()
        if text:
            return text.splitlines()[0][:200]
    return "Untitled session"

2. Only re-read what actually changed

Two layers. The first is a plain (size, mtime_ns) cache — note mtime_ns,
not int(st_mtime)
: with second precision, an edit inside the same second
that happens to leave the size unchanged (renaming a session to an equal-length
title does exactly that) is invisible forever.

st = os.stat(src)
prev = cache.get(src) or {}
if prev.get("size") == st.st_size and prev.get("mtime_ns") == st.st_mtime_ns:
    reuse(prev)                                # nothing touched the file
    continue

The second layer is what makes typing cheap. VS Code appends an inputState op
on every keystroke, so size and mtime change constantly while the content that
matters does not. Comparing a cheap content signature skips all of it:

def content_sig(state):
    """(turns, last timestamp, response part count, total characters).

    All four are needed: turns catches add/delete, the timestamp catches a new
    turn, and the character count catches a streaming answer growing while turn
    count and timestamp both stay put — and edits to a message in the middle.

    Only string lengths are summed; str()-ing dicts here is real CPU on a 28 MB
    session.
    """
    reqs = state.get("requests") or []
    if not reqs:
        return (0, 0, 0, 0)
    parts = total = 0
    for r in reqs:
        if not isinstance(r, dict):
            continue
        total += len(((r.get("message") or {}).get("text") or ""))
        resp = r.get("response") or []
        parts += len(resp)
        for part in resp:
            v = part.get("value") if isinstance(part, dict) else part
            total += len(v) if isinstance(v, str) else 1
    last = reqs[-1] if isinstance(reqs[-1], dict) else {}
    return (len(reqs), last.get("timestamp") or 0, parts, total)

Title is compared separately rather than folded into the signature: renaming a
session changes nothing else about it, and adding a 5th element would invalidate
every stored signature at once.

3. Seed IndexedDB, and let deletions propagate

The naive merge (union server ∪ local) resurrects sessions the user deleted. The
naive fix (server is authoritative, drop anything missing) deletes sessions that
are merely new, or that the server briefly failed to read.

Both failure modes bit me in practice — reading a session while it is being
written fails often enough to matter (~25 % of reads during active chat) — so
missing-from-server is treated as a suspicion that has to survive a time
window before it becomes a deletion:

const KEY = 'chat.ChatSessionStore.index';
const DELETE_GRACE_MS   = 300000;   // newer than the server's rebuild interval → never touch
const DELETE_CONFIRM_MS = 120000;   // must stay missing this long before we act

async function mergeIndex(dbName, serverEntries, pending) {
  const db = await openWithStore(dbName);          // creates ItemTable if absent
  const tx = db.transaction('ItemTable', 'readwrite');
  const store = tx.objectStore('ItemTable');
  const raw = await wrap(store.get(KEY));

  let index;
  try { index = raw ? JSON.parse(raw) : { version: 1, entries: {} }; }
  catch { index = { version: 1, entries: {} }; }
  if (!index.entries) index.entries = {};
  if (raw) await wrap(store.put(raw, KEY + '.backup'));   // one-level undo

  let added = 0, removed = 0;
  const now = Date.now();

  for (const [id, entry] of Object.entries(serverEntries)) {
    if (!index.entries[id]) { index.entries[id] = entry; added++; }
    pending.delete(id);                          // it is back: cancel any suspicion
  }

  for (const [id, entry] of Object.entries(index.entries)) {
    if (serverEntries[id]) continue;
    const age = now - (entry.lastMessageDate || 0);
    if (age < DELETE_GRACE_MS) continue;          // too new for the server to know about
    const since = pending.get(id);
    if (since === undefined) { pending.set(id, now); continue; }   // start watching
    if (now - since >= DELETE_CONFIRM_MS) {       // still gone → really deleted
      delete index.entries[id];
      pending.delete(id);
      removed++;
    }
  }

  if (added || removed) await wrap(store.put(JSON.stringify(index), KEY));
  await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = () => rej(tx.error); });
  db.close();
  return { added, removed };
}

Two details that are easy to get wrong:

  • Schedule a re-check when the window expires. "Confirm on the next poll"
    never fires: after a deletion the server data stops changing, so there may be
    no next poll. The timer has to be explicit.
  • onblocked must reject, not hang. Another tab holding an old connection
    will otherwise wedge the open forever.

4. Archive (the second half of the request)

Sessions get reset in place — VS Code reuses the sessionId and truncates
the file. A copier that mirrors the source loses everything that was there:

src_turns  = len(replay(src).get("requests") or [])
arch_turns = len(replay(archived).get("requests") or []) if exists(archived) else 0

if arch_turns and src_turns < arch_turns:
    # Source was reset. Keep the archive as-is (append-only), and save the
    # post-reset content beside it so both survive.
    side = f"{sid}.reset-{creation_stamp}.jsonl"
    if src_turns > side_turns:            # keep the longest post-reset version
        copy(src, side)
else:
    copy(src, archived)

Rendering each session to Markdown next to the raw .jsonl costs little and
makes the archive greppable. If you do that, note that model output frequently
contains unbalanced code fences; the archive has to close them itself or one bad
answer swallows the rest of the file. CommonMark rules apply — a closing fence
must be at least as long as the opener and carry no info string.

Notes from a working implementation

I built both as an injected script plus a small server-side daemon, and have run
it for a while on two code-server instances (562 session files, ~204 MB). A few
things that might save someone time:

Rebuilding the index is cheap if you cache by mtime. Full rebuild over all
sessions was ~1.7 s; with a (size, mtime_ns) cache the steady state is
effectively free. Doing it unconditionally on a timer is what makes it expensive.

Watch out for typing. VS Code appends an inputState op on every
keystroke
, so the session file changes constantly while the content that matters
does not. Reacting to raw file changes cost ~29 % CPU and rewrote megabytes per
minute. Deriving a content signature — (turns, last timestamp, response part count, total characters) — and skipping when it's unchanged brought that to
~7 % and zero writes.

Push beats polling. With inotify + SSE, a change is visible in other
browsers in ~100 ms. Also worth handling the degraded path explicitly: when
inotify is unavailable (instance limits are easy to hit when several
containers share a host), falling back to a real poll loop matters — falling
back to a 60 s safety rescan quietly makes sync 60× slower with no visible
symptom.

Snapshot ops. .jsonl sessions are op logs: kind: 0 is a full snapshot,
kind: 1 sets a key, kind: 2 appends to an array (this is how streamed
responses arrive). Replaying needs all three; handling only snapshots silently
loses in-flight answers.

Truncated files. A file written when the process is killed raises
EOFError / zlib.error on read, and neither is an OSError — a single bad
file can take down a whole pass if the handler is too narrow.

I'm happy to open a PR for either piece, or to share the implementation if
that's more useful than a patch. Also happy to be told this belongs upstream in
microsoft/vscode instead — my read is that it's specific to the web/remote
deployment shape that code-server has, which is why I'm raising it here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementSome improvement that isn't a feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions