#263

Stay connected to every saved environment

Opencalledforthwants to merge intofrom

What changes

The web app now holds a connection to every saved environment at once, instead of one to the selected environment. The interface still shows one environment, the selected one. Showing them together comes in later PRs.

  • Each saved environment has its own client, bootstrap question, route search, retry timer and socket, all started on load. One failing doesn't affect the others, and moving the selection doesn't touch a connection that is working.
  • An environment that can't be reached still shows its cached projects, sessions and unsent drafts after a reload. It didn't before, because a client only existed once a route had answered.
  • Opening a session with no connection leaves a chat that's already here on screen, instead of replacing it with "Could not open session". A session with nothing cached still shows that failure.

Why it's safe to create clients early

A client now exists before its route is verified, so these rules are what keep the token safe:

  • connect() is called from one effect, only on the answer to the latest question to that route, and only if it answers as that environment. That removes the old sticky "verified" state. A socket that was already open still retries on its own backoff after a drop, as it did. If a search then finds the route answers as another environment, that client goes.
  • Until the socket gets through, the page gets a view of the client without fetchArtifact and uploadArtifact. Those are the only calls that send the token over HTTP without the socket, and leaving them out means there's nothing to guard. Swapping to the real client also re-runs anything that read capabilities once.
  • A refused token is never redialled automatically. A typed connect clears a refusal only once it lands on that environment, and Retry during a connect is about that address only. The saved client stays alive during a connect and would otherwise redial straight away.
  • A refusal is now saved (tokenRefused on the record), so a reload doesn't spend the address's failed-token budget once per refused environment. The selected one is still dialled, because reloading the page you're on is asking for it. Selecting another is a person asking too.

Worth reviewing closely

This reverses a rule on purpose. #186 withheld cached data until the socket authenticated. This PR shows it before that, which is the point of the change. It's still hidden once the environment refuses the token, but a device whose token was revoked while it was offline can read its cache until it next reaches the environment.

Behaviour that changed:

  • Leaving an environment no longer ends its connection. Coming back to a working one is instant, and the old "starts over" behaviour now applies only to one that isn't through.
  • Bootstrap is asked per environment, so a typed connect is a separate question. It costs one more /bootstrap once it lands.
  • A socket report no longer stops at a pending typed connect.
  • The shell is keyed by environment ID. It used to remount because the client went null between environments, which no longer happens. Without the key, a draft could be written into the wrong environment. A client replaced on the same environment keeps the page.
  • Redeeming a pairing link waits for the connection, because a cached state names the environment before the socket is up.

Cache saves

Each save copies the whole state on the main thread, so several busy environments add up. An environment off screen now saves every 2 seconds instead of every 250 ms, and on pagehide. Measured with a stand-in for that copy, assuming each environment streams constantly:

State per environmentOne save5 environments, all at 250 ms1 at 250 ms, 4 at 2 s
100 sessions, 10 chats (3.8 MB)16 ms32% busy10%
100 sessions, 30 chats (12.7 MB)29 ms59% busy18%

The per-session model lists are about two thirds of the smaller state, so removing them would help here too, but it doesn't need to block this. The 1 MB socket limit is per socket, so more environments don't make it worse. One thing does change: an environment that trips that limit used to loop only while selected, and would now do so in the background.

Testing

  • Web 390, environment client 309 and shared app 496 tests pass. Typecheck and lint are clean.

  • New tests cover connecting everywhere on load, isolation between environments, the cached view, and the rules for opening a socket.

  • Each rule was broken on purpose to confirm a test fails. 25 breakages were tried and 23 are caught, after adding tests for 5 that weren't. That included two token rules: a verified route that later answers as another environment, and an old answer approving a new socket. The two left can't be seen from outside: a second layer of hiding on refusal, and memory cleanup when an environment is removed.

  • One independent Opus review found seven problems, all fixed in the second commit, each with a test. The fixes themselves haven't had a second review.

  • Run in Chromium against two real servers:

    • both sockets connect on load
    • switching environments in the UI opens and closes no socket, and the page changes
    • killing one leaves the other connected, with no banner about it
    • reloading onto the dead one shows its project with a banner naming it, and opens no socket or token request to it
    • a draft typed there survives a reload, marked "Not synced"
    • it reconnects on its own 3.3 seconds after the server returns, and the draft syncs

    That run covered projects and drafts. Reading a cached chat offline is covered only by the client test, because a session needs a real agent to create.

Under heavy parallel load some tests hit their 5 second timeout, including unrelated ones. They pass when the suites run on their own, as CI runs them.

Known gaps

  • The sidebar card of the open draft keeps its "Not synced" mark until you leave the draft, because that card is a snapshot taken on opening. The sync itself has finished by then.
  • Desktop and native mobile aren't touched.

🤖 Generated with Claude Code

Activity

4
cloudflare-workers-and-pages[bot]5h ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
69fd6b62026-10-30T18:27:47.506ZView logs ↗
  • Build: Failed ❌

View logs ↗
b8daeb52026-10-30T18:07:31.192ZView logs ↗