talk to two officers at once from one browser

The chat app on the iPad does this already and this is its model, not the music app one.
Music keeps its active server — one library at a time is the right question there. Chat is
the exception: two panels side by side, one on the laptop and one on alpha, both live, no
switching.

The mechanism is one string. A panel holds a serverId; that same string picks the base URL,
the credential, the websocket host and the tail of the react-query key. Nothing global is
consulted when it is named, which is exactly why two can be live at once — there is no
active server in connections.ts at all, because there is nothing to switch.

THIS ORIGIN IS NOT IN THE LIST. It is represented by null, so every existing useClient()
call is untouched and adding a connection cannot break the app you are already signed into.
That property is what makes this shippable before anyone has tried it.

A second server is reached with an ofk_ API key minted there, verified against /api/auth/me
before it is stored — a URL typo and a key from the wrong machine are otherwise
indistinguishable from an empty conversation list an hour later.

Copied deliberately from the mobile code: the base URL is derived per call rather than
memoised (a cached one hands back whichever server was asked for first), the row stamps its
server onto the selection BEFORE navigating (or the resolver reads the transcript from this
origin, where two officers can hold the same uuid), and changing server clears the cwd and
the open conversation, because a path from the machine you left names nothing on the one you
arrived at.

Not yet opened in a browser. Typecheck and 602 tests pass, and the cross-origin request with
an API key is verified by curl, but no human has clicked any of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 20:06:35 +01:00
co-authored by Claude Opus 5
parent 45e3e86d72
commit dc6b623ee1
10 changed files with 514 additions and 49 deletions
@@ -5,6 +5,7 @@ import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } fro
import { toast } from '@/components/ui/sonner';
import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient';
import { serverClient } from 'hooks/useServerClient';
import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
@@ -73,7 +74,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
if (isNew) {
// A new chat has no transcript to read a cwd from, so the group in the URL is the authority —
// and it has to be on the selection, because that is what the composer runs in.
setSelected({ id: `new:${Date.now()}`, cwd: groupCwd });
setSelected({ id: `new:${Date.now()}`, cwd: groupCwd, serverId: selectedRef.current?.serverId ?? null });
return;
}
if (!sessionId) return;
@@ -81,7 +82,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
let cancelled = false;
(async () => {
try {
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
// Read the transcript from the machine the row came from, not from this origin. The list
// stamps the server onto the selection before navigating, so it is known by the time this runs;
// a bare deep link has none and correctly resolves against this origin.
const remote = selectedRef.current?.serverId ?? null;
const detail = await serverClient(remote).get<ClaudeSessionDetail>(
`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`,
);
if (cancelled) return;
// The session's own cwd rides on the selection rather than being written back into the URL.
// It used to do both, and the URL copy was the one the composer read — so a deep link ran its
@@ -96,6 +103,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
cwd: detail.cwd,
title: detail.title,
partCount: detail.partCount,
serverId: remote,
});
} catch (err) {
if (cancelled) return;
@@ -103,7 +111,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// no hint that the transcript could not be read, which is indistinguishable from a new session.
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
setSelected({ id: sessionId });
setSelected({ id: sessionId, serverId: selectedRef.current?.serverId ?? null });
}
})();
return () => {