diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 6cbc06c7..0d24e5ca 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -5,7 +5,6 @@ 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'; @@ -74,7 +73,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, serverId: selectedRef.current?.serverId ?? null }); + setSelected({ id: `new:${Date.now()}`, cwd: groupCwd }); return; } if (!sessionId) return; @@ -82,13 +81,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { let cancelled = false; (async () => { try { - // 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}`, - ); + const detail = await client.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 @@ -103,7 +96,6 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { cwd: detail.cwd, title: detail.title, partCount: detail.partCount, - serverId: remote, }); } catch (err) { if (cancelled) return; @@ -111,7 +103,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, serverId: selectedRef.current?.serverId ?? null }); + setSelected({ id: sessionId }); } })(); return () => { diff --git a/src/workspaces/hooks/src/connections.ts b/src/workspaces/hooks/src/connections.ts deleted file mode 100644 index fd4d3ba2..00000000 --- a/src/workspaces/hooks/src/connections.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 6be88e50..9d7f3471 100644 --- a/src/workspaces/hooks/src/index.ts +++ b/src/workspaces/hooks/src/index.ts @@ -1,18 +1,6 @@ export { useGlobal } from './useGlobal'; export { usePanelChannel } from './usePanelChannel'; -export { useClient, createClient, resolveBearerToken } 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 { useClient, createClient } from './useClient'; 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 b72dacda..2792cb7f 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -1,48 +1,29 @@ import { useGlobal } from './useGlobal'; let theToken: string | null = null; -/** - * 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`). - */ -/** - * The session token for THIS origin, from every place one is allowed to live. - * - * Exported because it must not be re-spelled anywhere. A caller that reads only - * `localStorage.BEARER_TOKEN` — which the chat socket url did — authenticates for HTTP and fails for - * WebSockets the moment the token is held anywhere else: an embedded host setting - * `window.officerBearerToken`, a `?officerToken=` link, or sessionStorage. The app then loads, renders - * and lists history perfectly while the socket is refused with a 1002 and retries forever, which reads - * as a dead server rather than a missing credential. - */ -export const resolveBearerToken = (): string | null => - window.officerBearerToken || - document.body.dataset['officerBearerToken'] || - document.body.dataset['bearerToken'] || - new URL(window.location.href).searchParams.get('officerToken') || - localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') || - localStorage.getItem('BEARER_TOKEN') || - sessionStorage.getItem('BEARER_TOKEN'); - -export const createClient = (baseUrl: string = '/api', token?: string | null) => { - const lsToken = resolveBearerToken(); +export const createClient = (baseUrl: string = '/api') => { + const lsToken = + window.officerBearerToken || + document.body.dataset['officerBearerToken'] || + document.body.dataset['bearerToken'] || + new URL(window.location.href).searchParams.get('officerToken') || + localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') || + localStorage.getItem('BEARER_TOKEN') || + sessionStorage.getItem('BEARER_TOKEN'); theToken = lsToken; return { 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), + token: theToken, + get: (url: string) => get(url, baseUrl), + getText: (url: string) => getText(url, baseUrl), + getBlob: (url: string) => getBlob(url, baseUrl), // getStream: (url) => getStream(url, 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), + 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), }; }; @@ -56,15 +37,11 @@ export const useClient = (baseUrl: string = '/api') => { return { ...client, apiError, setApiError }; }; -export const getHeaders = (isText: boolean = false, token?: string | null) => { +export const getHeaders = (isText: boolean = false) => { const headers: Record = {}; - // `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}`; + if (theToken) { + headers['Authorization'] = `Bearer ${theToken}`; } headers['Content-Type'] = isText ? 'text/plain' : 'application/json'; @@ -83,8 +60,8 @@ const parseBody = async (res: Response): Promise => { return (text ? JSON.parse(text) : undefined) as T; }; -export const getText = async (uri: string, baseUrl = '', token?: string | null) => { - const headers = getHeaders(true, token); +export const getText = async (uri: string, baseUrl = '') => { + const headers = getHeaders(true); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -92,16 +69,16 @@ export const getText = async (uri: string, baseUrl = '', token?: string | null) return text; }; -export const get = async (uri: string, baseUrl = '', token?: string | null) => { - const headers = getHeaders(false, token); +export const get = async (uri: string, baseUrl = '') => { + const headers = getHeaders(); 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 = '', token?: string | null) => { - const headers = { Authorization: `Bearer ${token ?? theToken!}` }; +export const getBlob = async (uri: string, baseUrl = '') => { + const headers = { Authorization: `Bearer ${theToken!}` }; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -116,8 +93,8 @@ export const getBlob = async (uri: string, baseUrl = '', token?: string | null) // return stream; // }; -export const post = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { - const headers: Record = getHeaders(false, token); +export const post = async (uri: string, payload?: any, baseUrl = '') => { + const headers: Record = getHeaders(); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); if (payload instanceof FormData) { @@ -132,8 +109,8 @@ export const post = async (uri: string, payload?: any, baseUrl = '', token?: return parseBody(res); }; -export const put = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { - const headers: any = getHeaders(false, token); +export const put = async (uri: string, payload?: any, baseUrl = '') => { + const headers: any = getHeaders(); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); @@ -150,8 +127,8 @@ export const put = async (uri: string, payload?: any, baseUrl = '', token?: s return parseBody(res); }; -export const patch = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { - const headers = getHeaders(false, token); +export const patch = async (uri: string, payload?: any, baseUrl = '') => { + const headers = getHeaders(); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { method: 'PATCH', @@ -162,8 +139,8 @@ export const patch = async (uri: string, payload?: any, baseUrl = '', token?: return parseBody(res); }; -export const DELETE = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { - const headers = getHeaders(false, token); +export const DELETE = async (uri: string, payload?: any, baseUrl = '') => { + const headers = getHeaders(); 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 deleted file mode 100644 index 8bfffd90..00000000 --- a/src/workspaces/hooks/src/useServerClient.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 704901bb..105a447b 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -13,14 +13,6 @@ 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; @@ -148,12 +140,10 @@ 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, serverId } = props; + const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props; const location = useLocation(); const locationState = location.state as ChatLocationState; const { invalidate: invalidateClaudeSessions } = useClaudeSessions(); @@ -171,8 +161,6 @@ 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' @@ -242,7 +230,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, selected?.serverId); + const { sessions } = useClaudeSessions(selected?.cwd); 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 @@ -259,7 +247,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 deleted file mode 100644 index 59992b7f..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx +++ /dev/null @@ -1,185 +0,0 @@ -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 0c1eb3f9..4c836465 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -6,7 +6,6 @@ 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'; @@ -24,12 +23,7 @@ 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; - - // 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 { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [confirmingId, setConfirmingId] = useState(null); @@ -82,17 +76,7 @@ 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 }); - }} - /> +
{ @@ -113,7 +97,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, serverId }); + setSelected({ id: `new:${Date.now()}`, cwd: activeCwd }); navigate(chatNewPath(activeCwd), { replace: true }); }} // Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and @@ -205,11 +189,6 @@ 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 3e15c637..57c98b2b 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; -import { useServerClient, chatSocketUrl } from 'hooks/useServerClient'; -import { resolveBearerToken } from 'hooks/useClient'; +import { useClient } from 'hooks/useClient'; import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import { spliceRunningTasks } from '../apps/Chat/running-tasks'; @@ -28,14 +27,6 @@ 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; }; /** @@ -68,7 +59,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, initialMessages: preloadedMessages, paginate, onTurnComplete, - serverId, } = options ?? {}; const [messages, setMessages] = useState(preloadedMessages ?? []); /** @@ -144,10 +134,7 @@ 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); - // 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); + const client = useClient(); // 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. @@ -178,12 +165,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const hasMoreOlder = !!paginate && oldestOffset > 0; - // 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. - // The SAME resolution the HTTP client uses. Reading localStorage directly here meant a token held - // anywhere else authenticated every request and left the socket with `?token=`, refused 1002 and - // retrying forever — an app that loads and lists history but never connects. - const wsUrl = chatSocketUrl(serverId, resolveBearerToken()); + 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}`; function flushStreaming() { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); @@ -545,7 +529,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 30a3612c..26a09d3d 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -1,7 +1,6 @@ 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'; @@ -68,15 +67,12 @@ 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(serverId?: string | null) { - const client = useServerClient(serverId); +export function useChatPwds() { + const client = useClient(); const { isAuthenticated } = useAuth(); const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({ - // 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, + queryKey: ['CHAT_PWDS'], + enabled: isAuthenticated, queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'), staleTime: 30 * 1000, }); @@ -84,18 +80,15 @@ export function useChatPwds(serverId?: string | null) { } /** Sessions for a working directory, read from Claude's own transcript store (source of truth). */ -export function useClaudeSessions(cwd?: string | null, serverId?: string | null) { - const client = useServerClient(serverId); +export function useClaudeSessions(cwd?: string | null) { + const client = useClient(); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const q = cwdQuery(cwd); const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ - // 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, + queryKey: [SESSIONS_KEY, cwd ?? 'default'], + enabled: isAuthenticated, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), staleTime: 30 * 1000, });