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.

defreplay(path):
"""Rebuild session state from an op log. Returns the state dict, or None."""state= {}
withopen(path, "rt", encoding="utf-8", errors="replace") asfh:
forlineinfh:
line=line.strip()
ifnotline:
continuetry:
op=json.loads(line)
exceptValueError:
continue# half-written line, still being appendedkind, key, val=op.get("kind"), op.get("k"), op.get("v")
ifkind==0: # full snapshotstate=valifisinstance(val, dict) else {}
continueifnotisinstance(key, list) ornotkey:
continuecur=statetry:
forseginkey[:-1]:
cur=cur[seg]
last=key[-1]
ifkind==2: # append to array (streamed responses)tgt=cur[last] if (lastincurifisinstance(cur, dict) elseTrue) elseNoneifisinstance(tgt, list) andisinstance(val, list):
tgt.extend(val)
else:
cur[last] =valelse: # kind == 1: set keycur[last] =valexcept (KeyError, IndexError, TypeError):
continue# shape mismatch: skip this op, keep the sessionreturnstateorNonedefindex_entry(state):
"""One entry of chat.ChatSessionStore.index. Metadata only, no message content."""reqs=state.get("requests") or []
created=state.get("creationDate") or0last=createdforrinreqs:
ifisinstance(r, dict) andisinstance(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,
}
defsession_title(state):
"""customTitle if the user renamed it, else derive from the first question."""t=state.get("customTitle") orstate.get("title")
ift:
returnstr(t)[:200]
forrinstate.get("requests") or []:
text= ((r.get("message") or {}).get("text") or"").strip()
iftext:
returntext.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 {}
ifprev.get("size") ==st.st_sizeandprev.get("mtime_ns") ==st.st_mtime_ns:
reuse(prev) # nothing touched the filecontinue

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:

defcontent_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 []
ifnotreqs:
return (0, 0, 0, 0)
parts=total=0forrinreqs:
ifnotisinstance(r, dict):
continuetotal+=len(((r.get("message") or {}).get("text") or""))
resp=r.get("response") or []
parts+=len(resp)
forpartinresp:
v=part.get("value") ifisinstance(part, dict) elseparttotal+=len(v) ifisinstance(v, str) else1last=reqs[-1] ifisinstance(reqs[-1], dict) else {}
return (len(reqs), last.get("timestamp") or0, 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:

constKEY='chat.ChatSessionStore.index';constDELETE_GRACE_MS=300000;// newer than the server's rebuild interval → never touchconstDELETE_CONFIRM_MS=120000;// must stay missing this long before we actasyncfunctionmergeIndex(dbName,serverEntries,pending){constdb=awaitopenWithStore(dbName);// creates ItemTable if absentconsttx=db.transaction('ItemTable','readwrite');conststore=tx.objectStore('ItemTable');constraw=awaitwrap(store.get(KEY));letindex;try{index=raw ? JSON.parse(raw) : {version: 1,entries: {}};}catch{index={version: 1,entries: {}};}if(!index.entries)index.entries={};if(raw)awaitwrap(store.put(raw,KEY+'.backup'));// one-level undoletadded=0,removed=0;constnow=Date.now();for(const[id,entry]ofObject.entries(serverEntries)){if(!index.entries[id]){index.entries[id]=entry;added++;}pending.delete(id);// it is back: cancel any suspicion}for(const[id,entry]ofObject.entries(index.entries)){if(serverEntries[id])continue;constage=now-(entry.lastMessageDate||0);if(age<DELETE_GRACE_MS)continue;// too new for the server to know aboutconstsince=pending.get(id);if(since===undefined){pending.set(id,now);continue;}// start watchingif(now-since>=DELETE_CONFIRM_MS){// still gone → really deleteddeleteindex.entries[id];pending.delete(id);removed++;}}if(added||removed)awaitwrap(store.put(JSON.stringify(index),KEY));awaitnewPromise((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 []) ifexists(archived) else0ifarch_turnsandsrc_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"ifsrc_turns>side_turns: # keep the longest post-reset versioncopy(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