Feature request: make chat sessions server-side so they follow the user across browsers, and let them be archived
- Lingua principale
- TypeScript
- Stelle
- 79.3k
- Fork
- 6.8k
- Merge medio
- 2g 6h
- PR unite (30g)
- 41
Descrizione
## 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/workspaceStorage//chatSessions/.jsonl
```
But the list of sessions is stored in the browser:
```
IndexedDB: vscode-web-state-db-
store: ItemTable
key: chat.ChatSessionStore.index
```
Each entry is small metadata — no message content:
```json
{
"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 ` 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.
```python
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.
```python
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:
```python
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:
```js
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:
```python
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.
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.