From dc6b623ee19013439feeda2d001311a58248e4ae Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 20:06:35 +0100 Subject: [PATCH] talk to two officers at once from one browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Screens/Dashboard/ChatHistory/index.tsx | 14 +- src/workspaces/hooks/src/connections.ts | 146 ++++++++++++++ src/workspaces/hooks/src/index.ts | 12 ++ src/workspaces/hooks/src/useClient.ts | 63 +++--- src/workspaces/hooks/src/useServerClient.ts | 50 +++++ .../src/apps/ChatHistory/ChatDetailPanel.tsx | 19 +- .../src/apps/ChatHistory/ServerChips.tsx | 185 ++++++++++++++++++ .../src/apps/ChatHistory/SessionList.tsx | 27 ++- .../officerdev/src/hooks/useChat.ts | 24 ++- src/workspaces/state/src/useClaudeSessions.ts | 23 ++- 10 files changed, 514 insertions(+), 49 deletions(-) create mode 100644 src/workspaces/hooks/src/connections.ts create mode 100644 src/workspaces/hooks/src/useServerClient.ts create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 0d24e5ca..6cbc06c7 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -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(`/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( + `/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 () => { diff --git a/src/workspaces/hooks/src/connections.ts b/src/workspaces/hooks/src/connections.ts new file mode 100644 index 00000000..fd4d3ba2 --- /dev/null +++ b/src/workspaces/hooks/src/connections.ts @@ -0,0 +1,146 @@ +import { useSyncExternalStore } from 'react'; + +/** + * The other Officer servers this browser can talk to, and the credential for each. + * + * ## The model, which is the mobile chat app's and not the mobile app's + * + * `packages/core/src/services/servers.ts` in the mobile repo says "one active server at a time, + * always" — switching tears one session down and brings another up. That is right for Music, where the + * question "whose library is this" has one answer at a time, and that behaviour is untouched here. + * + * Chat is the deliberate exception. Andre works with two panes side by side, one talking to the laptop + * and one to alpha, both live, no switching. So there is **no active server in this module at all** — + * nothing to switch, nothing to leak. A caller names the server it wants or gets this origin. + * + * 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 every React Query key. Nothing global is consulted + * when it is named, which is precisely why two servers can be live at once. + * + * ## What lives here and what does not + * + * The list and its keys live in `localStorage`, because they must survive a reload and are per-browser + * by nature. What is deliberately NOT here is the layout — which tab holds which panes, and which pane + * is pointed where. A tab holding one conversation from the laptop and one from alpha belongs to + * neither server, so it is stored unscoped. + * + * ## THIS ORIGIN IS NOT IN THE LIST + * + * The server that served this page is always reachable and already authenticated by the session you + * signed in with. It is represented by `null`/absent — every existing `useClient()` call in the app + * passes no server and keeps working exactly as before. Adding a connection can therefore never break + * the app you are already using, which is the property that makes this safe to ship. + * + * ## The credential is an API key, not a password + * + * A second server is reached with an `ofk_…` key minted there (`POST /api/api-keys`). It carries the + * owner's full authority and does not expire, so it is a real secret sitting in `localStorage` — the + * same trade the mobile apps already make with their per-server tokens, on a device you control. + */ + +export type Connection = { + /** Derived from the URL, so re-adding the same server updates it rather than duplicating it. */ + id: string; + /** What to call it in the UI. Defaults to the host. */ + name: string; + /** Normalised, no trailing slash, includes the scheme. */ + baseUrl: string; + /** An `ofk_…` API key minted on THAT server. */ + key: string; +}; + +const STORE_KEY = 'officer.connections.v1'; + +/** + * Same derivation as the mobile app: the host, slugged. + * + * `https://example.com` and `example.com` are the same server and must not appear twice, so the scheme + * is only part of the id when it is explicitly insecure — otherwise adding a server by a slightly + * different URL silently produces a second entry with its own credential. + */ +export function connectionIdFor(rawUrl: string): string { + const url = normaliseUrl(rawUrl); + const { host, protocol } = new URL(url); + const slug = host.replace(/[^\w.-]+/g, '-'); + return protocol === 'http:' ? `http-${slug}` : slug; +} + +/** Assume https when no scheme is given — the mobile app's rule, and the safe default over a tailnet. */ +export function normaliseUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ''); + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + return withScheme; +} + +// ── Storage, with subscribers so React re-renders on a change ── + +let cache: Connection[] | null = null; +const listeners = new Set<() => void>(); + +function read(): Connection[] { + if (cache) return cache; + try { + const raw = localStorage.getItem(STORE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : []; + cache = Array.isArray(parsed) ? (parsed as Connection[]).filter((c) => c?.id && c?.baseUrl && c?.key) : []; + } catch { + cache = []; // unreadable or not JSON — an empty list degrades to "this origin only" + } + return cache; +} + +function write(next: Connection[]): void { + cache = next; + try { + localStorage.setItem(STORE_KEY, JSON.stringify(next)); + } catch { + /* quota or private mode — the list still works for this page's lifetime */ + } + for (const listener of listeners) listener(); +} + +export function listConnections(): Connection[] { + return read(); +} + +export function getConnection(serverId: string | null | undefined): Connection | null { + if (!serverId) return null; + return read().find((c) => c.id === serverId) ?? null; +} + +export function upsertConnection(input: { url: string; key: string; name?: string }): Connection { + const baseUrl = normaliseUrl(input.url); + const id = connectionIdFor(baseUrl); + const connection: Connection = { + id, + name: input.name?.trim() || new URL(baseUrl).host, + baseUrl, + key: input.key.trim(), + }; + write([...read().filter((c) => c.id !== id), connection]); + return connection; +} + +export function removeConnection(serverId: string): void { + write(read().filter((c) => c.id !== serverId)); +} + +// `useSyncExternalStore` rather than a context: the store is a module singleton and every panel reads +// it independently, so there is no provider to place and no re-render cascade to reason about. +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export function useConnections(): Connection[] { + return useSyncExternalStore(subscribe, listConnections, () => []); +} + +/** + * The label for a server id, for a badge that says which machine a conversation is on. + * + * `null` means this origin, which is deliberately not in the list — see the module comment. + */ +export function connectionLabel(serverId: string | null | undefined, fallback = 'This server'): string { + return getConnection(serverId)?.name ?? fallback; +} diff --git a/src/workspaces/hooks/src/index.ts b/src/workspaces/hooks/src/index.ts index 9d7f3471..57e71466 100644 --- a/src/workspaces/hooks/src/index.ts +++ b/src/workspaces/hooks/src/index.ts @@ -1,6 +1,18 @@ export { useGlobal } from './useGlobal'; export { usePanelChannel } from './usePanelChannel'; export { useClient, createClient } from './useClient'; +export { + useConnections, + listConnections, + getConnection, + upsertConnection, + removeConnection, + connectionLabel, + connectionIdFor, + normaliseUrl, +} from './connections'; +export type { Connection } from './connections'; +export { useServerClient, serverClient, chatSocketUrl } from './useServerClient'; export { useDebounce } from './useDebounce'; export { useDragAndDrop } from './useDragAndDrop'; export { useImageLoader } from './useImageLoader'; diff --git a/src/workspaces/hooks/src/useClient.ts b/src/workspaces/hooks/src/useClient.ts index 2792cb7f..81c13655 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -1,7 +1,14 @@ import { useGlobal } from './useGlobal'; let theToken: string | null = null; -export const createClient = (baseUrl: string = '/api') => { +/** + * A client bound to one server. + * + * `baseUrl`/`token` omitted means this origin with the session you signed in with — every existing + * caller. Naming both is how a panel talks to ANOTHER Officer without any global being switched, which + * is what lets two panes hold two live conversations on two machines (see `connections.ts`). + */ +export const createClient = (baseUrl: string = '/api', token?: string | null) => { const lsToken = window.officerBearerToken || document.body.dataset['officerBearerToken'] || @@ -15,15 +22,15 @@ export const createClient = (baseUrl: string = '/api') => { return { baseUrl, - token: theToken, - get: (url: string) => get(url, baseUrl), - getText: (url: string) => getText(url, baseUrl), - getBlob: (url: string) => getBlob(url, baseUrl), + token: token ?? theToken, + get: (url: string) => get(url, baseUrl, token), + getText: (url: string) => getText(url, baseUrl, token), + getBlob: (url: string) => getBlob(url, baseUrl, token), // getStream: (url) => getStream(url, baseUrl), - post: (url: string, payload?: any) => post(url, payload, baseUrl), - put: (url: string, payload?: any) => put(url, payload, baseUrl), - patch: (url: string, payload?: any) => patch(url, payload, baseUrl), - delete: (url: string, payload?: any) => DELETE(url, payload, baseUrl), + post: (url: string, payload?: any) => post(url, payload, baseUrl, token), + put: (url: string, payload?: any) => put(url, payload, baseUrl, token), + patch: (url: string, payload?: any) => patch(url, payload, baseUrl, token), + delete: (url: string, payload?: any) => DELETE(url, payload, baseUrl, token), }; }; @@ -37,11 +44,15 @@ export const useClient = (baseUrl: string = '/api') => { return { ...client, apiError, setApiError }; }; -export const getHeaders = (isText: boolean = false) => { +export const getHeaders = (isText: boolean = false, token?: string | null) => { const headers: Record = {}; - if (theToken) { - headers['Authorization'] = `Bearer ${theToken}`; + // `token` names a specific server's credential; omitted falls back to this origin's session, which is + // every existing caller. See `connections.ts` — a second server is reached by naming it, never by + // switching a global, so nothing here changes for the app you are already signed into. + const effective = token ?? theToken; + if (effective) { + headers['Authorization'] = `Bearer ${effective}`; } headers['Content-Type'] = isText ? 'text/plain' : 'application/json'; @@ -60,8 +71,8 @@ const parseBody = async (res: Response): Promise => { return (text ? JSON.parse(text) : undefined) as T; }; -export const getText = async (uri: string, baseUrl = '') => { - const headers = getHeaders(true); +export const getText = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = getHeaders(true, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -69,16 +80,16 @@ export const getText = async (uri: string, baseUrl = '') => { return text; }; -export const get = async (uri: string, baseUrl = '') => { - const headers = getHeaders(); +export const get = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); return parseBody(res); }; -export const getBlob = async (uri: string, baseUrl = '') => { - const headers = { Authorization: `Bearer ${theToken!}` }; +export const getBlob = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = { Authorization: `Bearer ${token ?? theToken!}` }; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -93,8 +104,8 @@ export const getBlob = async (uri: string, baseUrl = '') => { // return stream; // }; -export const post = async (uri: string, payload?: any, baseUrl = '') => { - const headers: Record = getHeaders(); +export const post = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers: Record = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); if (payload instanceof FormData) { @@ -109,8 +120,8 @@ export const post = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const put = async (uri: string, payload?: any, baseUrl = '') => { - const headers: any = getHeaders(); +export const put = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers: any = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); @@ -127,8 +138,8 @@ export const put = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const patch = async (uri: string, payload?: any, baseUrl = '') => { - const headers = getHeaders(); +export const patch = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { method: 'PATCH', @@ -139,8 +150,8 @@ export const patch = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const DELETE = async (uri: string, payload?: any, baseUrl = '') => { - const headers = getHeaders(); +export const DELETE = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { method: 'delete', diff --git a/src/workspaces/hooks/src/useServerClient.ts b/src/workspaces/hooks/src/useServerClient.ts new file mode 100644 index 00000000..8bfffd90 --- /dev/null +++ b/src/workspaces/hooks/src/useServerClient.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { createClient } from './useClient'; +import { getConnection } from './connections'; + +/** + * A client for one named server, or for this origin when nothing is named. + * + * This is the single point where a `serverId` becomes a URL and a credential, and the two MUST come + * from the same place: a key minted on one host is meaningless to another, and presenting it produces a + * 401 that reads exactly like an expired session. Deriving both here makes that mismatch unspeakable. + * + * Deliberately NOT memoised across ids — a cached "current base URL" is the classic bug in this design, + * because it hands back whichever server was asked for first and every later pane inherits it. The + * mobile app hit this and left a comment about it (`api.ts:86-88`); the cheap fix is to derive per call + * and let React memoise on the id. + */ +export function serverClient(serverId?: string | null) { + const connection = getConnection(serverId); + // No connection → this origin, `/api`, and the session token the app already holds. That is every + // existing call site in the app, unchanged. + if (!connection) return createClient('/api'); + return createClient(`${connection.baseUrl}/api`, connection.key); +} + +export function useServerClient(serverId?: string | null) { + return useMemo(() => serverClient(serverId), [serverId]); +} + +/** + * The WebSocket URL for a server, with its credential in the query string. + * + * A browser WebSocket cannot set headers, so the token rides the query — the same shape the mobile app + * uses and the same one `/api/chat/ws` already accepts. `http→ws` by prefix swap, which gets `wss` for + * an https server for free. + * + * Returns null when a named server is unknown, so a caller opens no socket rather than dialling this + * origin under another server's name. + */ +export function chatSocketUrl(serverId: string | null | undefined, sessionToken: string | null): string | null { + const connection = getConnection(serverId); + + if (!connection) { + if (serverId) return null; // named but missing — say nothing rather than guess + const origin = window.location.origin.replace(/^http/, 'ws'); + return `${origin}/api/chat/ws?token=${encodeURIComponent(sessionToken ?? '')}`; + } + + const base = connection.baseUrl.replace(/^http/, 'ws'); + return `${base}/api/chat/ws?token=${encodeURIComponent(connection.key)}`; +} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 105a447b..704901bb 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -13,6 +13,14 @@ import type { ChatMessage } from '../Chat/types'; export type SelectedSession = { id: string; + /** + * Which Officer this conversation lives on. Absent = the one that served this page. + * + * It rides the selection rather than living in a global, for the same reason the mobile chat app puts + * it on its `OpenTarget`: two panels can then hold conversations on two different machines at once. + * A global "current server" would make that unrepresentable. + */ + serverId?: string | null; model?: string | null; resumeSummary?: string; resumeSessionId?: string; @@ -140,10 +148,12 @@ type NewChatProps = { sessionCwd?: string | null; sessionTitle?: string | null; partCount?: number; + /** Which Officer runs this conversation. Absent = the one that served this page. */ + serverId?: string | null; }; function NewChat(props: NewChatProps) { - const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props; + const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, serverId } = props; const location = useLocation(); const locationState = location.state as ChatLocationState; const { invalidate: invalidateClaudeSessions } = useClaudeSessions(); @@ -161,6 +171,8 @@ function NewChat(props: NewChatProps) { initialMessages, onTurnComplete, context: 'chat', + // The one value that sends this conversation's requests and its socket to another machine. + serverId, // Only the tail is loaded up front — let the chat page older messages upward on scroll. paginate: resumeSessionId && typeof total === 'number' @@ -230,7 +242,7 @@ export const ChatDetailPanel = () => { // — so a rename reached the server, refreshed the row, and left this pane and the page title showing // the old name, which reads exactly like the rename having failed. Resolved here, once, and passed // down: renaming from either surface now retitles both, and the list's own pencil retitles an open pane. - const { sessions } = useClaudeSessions(selected?.cwd); + const { sessions } = useClaudeSessions(selected?.cwd, selected?.serverId); const title = sessions.find((session) => session.id === sessionId)?.title ?? selected?.title ?? null; // The id rides along so the shell can tell a rename of this conversation from opening a different one @@ -247,7 +259,7 @@ export const ChatDetailPanel = () => { return ( { sessionCwd={selected.cwd} sessionTitle={title} partCount={selected.partCount} + serverId={selected.serverId} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx new file mode 100644 index 00000000..59992b7f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Plus, Server, Trash2 } from 'lucide-react'; +import { useConnections, upsertConnection, removeConnection } from 'hooks/connections'; + +/** + * Which Officer this panel is talking to. + * + * Chips rather than a dropdown, and no "active server" anywhere: the choice belongs to THIS panel, so + * two panels side by side can sit on two machines with both conversations live. That is the whole + * feature — a switcher would be the opposite of it. + * + * Hidden entirely until a second server exists, so a single-server browser looks exactly as it did. + */ +type ServerChipsProps = { + value: string | null; + onChange: (serverId: string | null) => void; +}; + +export const ServerChips = ({ value, onChange }: ServerChipsProps) => { + const connections = useConnections(); + const [adding, setAdding] = useState(false); + + // Nothing to choose between — but the add affordance still has to exist, or a second server can never + // be added in the first place. + if (connections.length === 0 && !adding) { + return ( + + ); + } + + return ( +
+ onChange(null)} /> + {connections.map((connection) => ( + onChange(connection.id)} + onRemove={() => { + removeConnection(connection.id); + // Panels pointed at it must not keep asking a server that no longer exists. + if (value === connection.id) onChange(null); + }} + /> + ))} + + {adding && setAdding(false)} onAdded={(id) => onChange(id)} />} +
+ ); +}; + +const Chip = ({ + label, + active, + onClick, + onRemove, +}: { + label: string; + active: boolean; + onClick: () => void; + onRemove?: () => void; +}) => ( + + + {onRemove && ( + + )} + +); + +/** + * Add a server by URL and API key. + * + * A key (`ofk_…`) rather than email and password, deliberately: this browser already holds a session for + * the server that served it, and a second server needs a credential that does not depend on signing in + * here. Mint one on the other machine with `POST /api/api-keys`. + * + * The key is verified against `/api/auth/me` before it is stored — a URL typo or a key from the wrong + * machine is otherwise indistinguishable from an empty conversation list later on. + */ +const AddServerForm = ({ onDone, onAdded }: { onDone: () => void; onAdded: (serverId: string) => void }) => { + const [url, setUrl] = useState(''); + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async () => { + setBusy(true); + setError(null); + try { + const base = /^https?:\/\//i.test(url.trim()) ? url.trim().replace(/\/+$/, '') : `https://${url.trim()}`; + const res = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${key.trim()}` } }); + if (!res.ok) throw new Error(res.status === 401 ? 'That key was rejected' : `Server answered ${res.status}`); + const me = (await res.json()) as { email?: string }; + const connection = upsertConnection({ url: base, key, name: name || me.email }); + onAdded(connection.id); + onDone(); + } catch (err) { + // A cross-origin failure lands here as a TypeError with no detail, which is worth naming: over + // HTTPS the browser blocks a plain-http server outright and no code of ours ever runs. + const message = err instanceof Error ? err.message : String(err); + setError( + message === 'Failed to fetch' + ? 'Could not reach it. If this page is HTTPS, the other server must be too.' + : message, + ); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ Add an Officer server +
+ setUrl(ev.target.value)} + placeholder="macbook.pastilhas.dev" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + setKey(ev.target.value)} + placeholder="ofk_… (API key from that server)" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + setName(ev.target.value)} + placeholder="Name (optional)" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + {error &&
{error}
} +
+ + +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 4c836465..0c1eb3f9 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -6,6 +6,7 @@ import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, Rela import { useSelectedChatSession } from '../../channels'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; +import { ServerChips } from './ServerChips'; import type { SelectedSession } from './ChatDetailPanel'; import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { PwdSelector } from './PwdSelector'; @@ -23,7 +24,12 @@ export const SessionList = () => { // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; - const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); + + // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another + // machine is the entire point, and a shared "current server" would make that impossible to express. + // Seeded from the open conversation so a deep link into a remote session keeps its list on that host. + const [serverId, setServerId] = useState(selected?.serverId ?? null); + const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd, serverId); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [confirmingId, setConfirmingId] = useState(null); @@ -76,7 +82,17 @@ export const SessionList = () => { return (
-
+
+ { + setServerId(next); + // A path and a conversation from the machine you left name nothing on the one you arrived + // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). + setSelected(null); + navigate(chatListPath(null), { replace: true }); + }} + /> { @@ -97,7 +113,7 @@ export const SessionList = () => { onClick={() => { // A new chat starts in the group the list is showing, and says so in both places: on the // selection (which is what the composer actually runs in) and in the URL. - setSelected({ id: `new:${Date.now()}`, cwd: activeCwd }); + setSelected({ id: `new:${Date.now()}`, cwd: activeCwd, serverId }); navigate(chatNewPath(activeCwd), { replace: true }); }} // Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and @@ -189,6 +205,11 @@ export const SessionList = () => { not a thing, and nesting them is what breaks cmd-click on half the app's lists. */} setSelected({ id: session.id, cwd: session.cwd, title: session.title, serverId })} title={session.title} selected={isActive} className="min-w-0 flex-1" diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 57c98b2b..57b76ace 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; -import { useClient } from 'hooks/useClient'; +import { useServerClient, chatSocketUrl } from 'hooks/useServerClient'; import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import { spliceRunningTasks } from '../apps/Chat/running-tasks'; @@ -27,6 +27,14 @@ type UsePiChatOptions = { // When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in. paginate?: { sessionId: string; total: number; initialOffset: number }; onTurnComplete?: (hadToolCalls: boolean) => void; + /** + * Which Officer this conversation lives on. Absent = the one that served this page. + * + * This is the ONLY thing that makes a panel talk to another machine, and it is deliberately a plain + * string rather than a context: two panels side by side hold two different values, which is what lets + * one conversation run on the laptop while another runs on alpha, both live, with no switching. + */ + serverId?: string | null; }; /** @@ -59,6 +67,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, initialMessages: preloadedMessages, paginate, onTurnComplete, + serverId, } = options ?? {}; const [messages, setMessages] = useState(preloadedMessages ?? []); /** @@ -134,7 +143,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // reattachable the moment the harness names its transcript rather than when the turn finishes. const claudeSessionIdRef = useRef(resumeSessionId ?? null); - const client = useClient(); + // The one string that makes this panel talk to another machine. Absent = this origin, which is every + // existing caller. It picks the URL, the credential and the socket host together — a key minted on one + // host is meaningless to another, so they must never be derived separately. + const client = useServerClient(serverId); // Fetch the next older window and prepend it. The scroll container restores its position from the // height delta so the view stays put. Guarded against overlap and against running once fully paged in. @@ -165,9 +177,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const hasMoreOlder = !!paginate && oldestOffset > 0; - const token = localStorage.getItem('BEARER_TOKEN'); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`; + // Null when a named server is unknown, which opens no socket rather than dialling this origin under + // another server's name — the failure that would put one machine's turn in another's pane. + const wsUrl = chatSocketUrl(serverId, localStorage.getItem('BEARER_TOKEN')); function flushStreaming() { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); @@ -529,7 +541,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, }); }, [sendAttach]); - const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen }); + const { isConnected, send } = useChatWebSocket({ url: wsUrl ?? '', onMessage: handleMessage, onOpen }); sendRef.current = send; // `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 26a09d3d..30a3612c 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import { useClient } from 'hooks/useClient'; +import { useServerClient } from 'hooks/useServerClient'; import { useAuth } from 'hooks/useAuth'; const SESSIONS_KEY = 'CLAUDE_SESSIONS'; @@ -67,12 +68,15 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''); /** The default /chat dir plus every directory that already has Claude sessions. */ -export function useChatPwds() { - const client = useClient(); +export function useChatPwds(serverId?: string | null) { + const client = useServerClient(serverId); const { isAuthenticated } = useAuth(); const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({ - queryKey: ['CHAT_PWDS'], - enabled: isAuthenticated, + // Server-scoped: two machines have different working directories, and an unscoped key would show + // one machine's folders under the other's name. + queryKey: ['CHAT_PWDS', serverId ?? null], + // A named server carries its own API key, so it does not depend on this origin's session. + enabled: isAuthenticated || !!serverId, queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'), staleTime: 30 * 1000, }); @@ -80,15 +84,18 @@ export function useChatPwds() { } /** Sessions for a working directory, read from Claude's own transcript store (source of truth). */ -export function useClaudeSessions(cwd?: string | null) { - const client = useClient(); +export function useClaudeSessions(cwd?: string | null, serverId?: string | null) { + const client = useServerClient(serverId); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const q = cwdQuery(cwd); const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ - queryKey: [SESSIONS_KEY, cwd ?? 'default'], - enabled: isAuthenticated, + // The server id is part of the key because two Officers can hold transcripts with the SAME uuid — + // without it the cache hands one machine's conversation to the other, which looks like a UI glitch + // while actually being the wrong server's data under the right server's name. + queryKey: [SESSIONS_KEY, cwd ?? 'default', serverId ?? null], + enabled: isAuthenticated || !!serverId, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), staleTime: 30 * 1000, });