coder / coder/code-server

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

Abierto
#7,948 1 comentario 2 reacciones 0 asignados Ver en GitHub
enhancement
Lenguaje dominante
TypeScript
Estrellas
79.3k
Forks
6.8k
Merge medio
2 d 6 h
PR fusionados (30 d)
41

Descripción

## 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.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.