Skip to content

Self-hosting survival: persist chat + CLI session IDs across reloads#66

Merged
smorchj merged 1 commit intomainfrom
feat/self-hosting-session-survival
Apr 15, 2026
Merged

Self-hosting survival: persist chat + CLI session IDs across reloads#66
smorchj merged 1 commit intomainfrom
feat/self-hosting-session-survival

Conversation

@smorchj
Copy link
Copy Markdown
Owner

@smorchj smorchj commented Apr 15, 2026

Why

The critical path for using Klonode to edit Klonode. When you edit a server-side file (API route, store, Vite config) while a Claude session is running, Vite restarts the dev server and triggers a full page reload. Before this PR, three things died on that reload:

  1. Chat historychatStore.messages was never persisted. Every reload = empty scrollback.
  2. Claude CLI session ID mapping — lived in a component-local `const cliSessionIds: Record<string, string>` inside ChatPanel.svelte. Reset on every remount. Every reload = next message starts a fresh conversation, losing all of Claude's accumulated context.
  3. User had no signal — the loading spinner for the in-flight response just vanished, leaving a blank assistant bubble.

With all three lost, self-hosting was unworkable: one typo in a store file or one edit to `api/chat/stream/+server.ts` burned the entire conversation.

What now survives a reload

  • Chat history (`klonode-chat` localStorage key, last 80 messages). `chatStore` auto-persists on every update via a subscribe. Transient state (`isLoading`, `error`, `lastComparison`) is left out of the snapshot.
  • CLI session ID per tab (`klonode-sessions.cliSessionIds`). Moved from a component-local const into `SessionsState` with `getCliSessionId` / `setCliSessionId` / `clearCliSessionId` helpers exported from `agents.ts`. The next message after a reload resumes the same Claude conversation.
  • Per-session message backlog (`sessions.messages`, last 50 per session). The agents API / CO analysis path was already reading from here but it was never persisted. Now it is.
  • Date timestamps — rehydration converts ISO strings back to Date objects so `.toISOString()` / formatting code doesn't throw.
  • Interrupted flag — any message that was `loading: true` at save time gets `loading: false, interrupted: true` on hydrate. The UI renders a `⚠ response interrupted by reload` banner instead of a spinner that never resolves.

UX additions

  • Pulsing amber `● streaming` badge in the chat header while a response is in flight. Tooltip explains that editing a server-side file will interrupt it.
  • `⚠ response interrupted by reload` banner on any assistant message that was cut off.

Verified end-to-end with preview

  1. Preview server up, page loaded, console clean.
  2. Planted 3 messages (user + complete assistant + mid-stream loading) into `chatStore`, and a fake CLI session ID onto the active tab's `cliSessionIds`.
  3. Called `location.reload()` — full page reload.
  4. Inspected post-reload state:
    • `chatMsgCount: 3` ✓
    • `firstUserMsg: 'Rename getUserById to loadUser'` ✓
    • `cliSessionIdForActive: 'claude-cli-abc-123'` ✓
    • `lastMessage.loading: false, interrupted: true` ✓
    • `isLoading: false` (no stuck spinner) ✓
  5. `preview_inspect '.interrupted-banner'` — rendered, amber color (`rgb(251, 191, 36)`), correct text.

No console errors. No regressions in the existing UI.

Storage safety

  • `MAX_PERSISTED_CHAT_MESSAGES = 80` and `MAX_PERSISTED_MESSAGES_PER_SESSION = 50` bound the footprint.
  • Both `saveState` paths catch `QuotaExceededError` and fall back to persisting session metadata (IDs, active tab, CLI session IDs) without the message backlog. Losing history is cheaper than losing conversation continuity.

Documentation

`docs/self-hosting.md` explains which edit paths are safe during an active chat (`.svelte` files → HMR, everything survives) and which trigger a reload (`.ts` stores, `api/**` routes, Vite config). Includes a safe-vs-risky table, a risky-edit playbook, and pointers to the implementation files for anyone debugging a survival regression later.

Independent of #65

This branch is stacked on main, not on `feat/workstation-self-introspection`. Both PRs are orthogonal and can merge in either order.

Closes

Addresses the self-hosting blocker the original #53 was reaching for. A new tighter tracking issue for the remaining self-hosting work (stream resume across server restarts, worktree-based safer edit paths) can be filed after this lands.

The critical path for using Klonode to edit Klonode: editing a
server-side file (API route, store, config) triggers a Vite dev server
restart and full page reload. Before this change, three things died on
the reload:

1. Chat history (chatStore.messages was never persisted)
2. Claude CLI session ID mapping (a component-local const in
   ChatPanel.svelte that reset on every remount)
3. The user had no signal that a response had been interrupted — the
   loading spinner just vanished

With all three lost, every store-file or API-route edit burned all of
Claude's accumulated conversation context. Self-hosting was
unworkable.

## What now survives

- **Chat history** (klonode-chat, last 80 messages). chatStore auto-
  persists on every update. Transient state (isLoading, error,
  lastComparison) is left out of the snapshot.
- **CLI session ID per tab** (klonode-sessions.cliSessionIds).
  Moved out of ChatPanel's local const into SessionsState with
  getCliSessionId / setCliSessionId / clearCliSessionId helpers. The
  next message after a reload resumes the same Claude conversation
  rather than spawning a fresh one.
- **Per-session message backlog** (sessions.messages, last 50 per
  session). The agents API / CO analysis path already read from here
  but it was never persisted.
- **Date timestamps** — rehydration now converts ISO strings back to
  Date objects so .toISOString() / formatting code doesn't throw.
- **Interrupted flag** — any message that was `loading: true` at save
  time gets `loading: false, interrupted: true` on hydrate so the UI
  can render a "response interrupted by reload" banner instead of a
  spinner that never resolves.

## What the user sees

- A pulsing amber `● streaming` badge in the chat header while a
  response is in flight, with a tooltip explaining that editing a
  server-side file will interrupt it.
- A `⚠ response interrupted by reload` banner on any assistant
  message that was cut off by a reload, so it's clear what happened
  rather than leaving a blank bubble.

## Storage safety

- `MAX_PERSISTED_CHAT_MESSAGES = 80` and
  `MAX_PERSISTED_MESSAGES_PER_SESSION = 50` cap the per-store footprint
  so a long conversation can't blow through the ~5 MB localStorage
  quota.
- Both save paths catch QuotaExceededError and fall back to persisting
  session metadata (IDs, active tab, CLI session IDs) without the
  message backlog, since losing history is much cheaper than losing
  conversation continuity.

## Documented

docs/self-hosting.md explains which edit paths are safe during an
active chat (component files → HMR, history survives), which trigger a
full reload (stores, API routes, config), and the implementation
pointers for anyone debugging a survival regression later.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant