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, });