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
- Open code-server in browser A, have a few chats.
- Open the same code-server URL in browser B (or a different device).
- Browser B shows an empty chat list.
- 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.
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:
But the list of sessions is stored in the browser:
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
…/chatSessions/*.jsonlstill contains everything.Why this is worse than it sounds
switching devices makes the history vanish with no message and no way back
through the UI.
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.
Proposal
1. Serve the chat session index from the server
The server can rebuild that index by scanning
chatSessions/*.jsonl— it needssessionId, a title, and timestamps, all of which are in the files. The browserwould 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 itgrows. Two things make it worth more than a plain backup:
sessionIdandtruncates 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.
.jsonlmakes the archivegreppable 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
.jsonlsessions are op logs, not documents. Three op kinds matter, andskipping any of them silently loses data —
kind: 2in particular is howstreamed responses arrive, so handling only snapshots drops in-flight answers.
2. Only re-read what actually changed
Two layers. The first is a plain
(size, mtime_ns)cache — notemtime_ns,not
int(st_mtime): with second precision, an edit inside the same secondthat happens to leave the size unchanged (renaming a session to an equal-length
title does exactly that) is invisible forever.
The second layer is what makes typing cheap. VS Code appends an
inputStateopon every keystroke, so size and mtime change constantly while the content that
matters does not. Comparing a cheap content signature skips all of it:
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:
Two details that are easy to get wrong:
never fires: after a deletion the server data stops changing, so there may be
no next poll. The timer has to be explicit.
onblockedmust reject, not hang. Another tab holding an old connectionwill otherwise wedge the open forever.
4. Archive (the second half of the request)
Sessions get reset in place — VS Code reuses the
sessionIdand truncatesthe file. A copier that mirrors the source loses everything that was there:
Rendering each session to Markdown next to the raw
.jsonlcosts little andmakes 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 iseffectively free. Doing it unconditionally on a timer is what makes it expensive.
Watch out for typing. VS Code appends an
inputStateop on everykeystroke, 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 otherbrowsers in ~100 ms. Also worth handling the degraded path explicitly: when
inotifyis unavailable (instance limits are easy to hit when severalcontainers 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.
.jsonlsessions are op logs:kind: 0is a full snapshot,kind: 1sets a key,kind: 2appends to an array (this is how streamedresponses 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.erroron read, and neither is anOSError— a single badfile 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/vscodeinstead — my read is that it's specific to the web/remotedeployment shape that code-server has, which is why I'm raising it here.