revert the second server too: one officer, one token, this origin

Andre wants to log out and log back in against a single server, so this takes out dc6b623 and my
token-resolution change with it — the latter first, because it was written against
useServerClient, which dc6b623 introduced.

Gone: the connections store, the server chips, the per-server client and the per-server socket
url. `useClient()` is back to one origin, `/api`, and the session it already holds. The chat
socket url is back to what it was:

  const token = localStorage.getItem('BEARER_TOKEN');
  const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;

Verified: the staged tree is byte-identical to dc6b623^ across all of src/.

Two things he should know rather than discover.

The old line reads localStorage and nothing else — the same single spelling I widened an hour ago
and have now removed again. If his token is NOT in localStorage, this code fails exactly as
before, and worse: a missing one interpolates as the literal string "null" rather than an empty
value. Reverting cannot fix that class of problem; it restores it.

`officer.connections.v1` stays in his browser's localStorage with alpha's API key in it. Nothing
reads it now, so it is inert, but it is a credential sitting in a store nobody owns any more and
should be cleared by hand.

Typecheck clean. 600 pass, 2 fail — cliamp and pty, unchanged all evening and unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:37:02 +01:00
co-authored by Claude Opus 5
parent 52d567874f
commit 31ffe084f5
10 changed files with 58 additions and 539 deletions
@@ -5,7 +5,6 @@ import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } fro
import { toast } from '@/components/ui/sonner'; import { toast } from '@/components/ui/sonner';
import { useIsMobile } from 'hooks/useIsMobile'; import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { serverClient } from 'hooks/useServerClient';
import { errorText } from 'helpers/error-text'; import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
@@ -74,7 +73,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
if (isNew) { if (isNew) {
// A new chat has no transcript to read a cwd from, so the group in the URL is the authority — // 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. // 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; return;
} }
if (!sessionId) return; if (!sessionId) return;
@@ -82,13 +81,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
// Read the transcript from the machine the row came from, not from this origin. The list const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
// stamps the server onto the selection before navigating, so it is known by the time this runs;
// a bare deep link has none and correctly resolves against this origin.
const remote = selectedRef.current?.serverId ?? null;
const detail = await serverClient(remote).get<ClaudeSessionDetail>(
`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`,
);
if (cancelled) return; if (cancelled) return;
// The session's own cwd rides on the selection rather than being written back into the URL. // 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 // 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, cwd: detail.cwd,
title: detail.title, title: detail.title,
partCount: detail.partCount, partCount: detail.partCount,
serverId: remote,
}); });
} catch (err) { } catch (err) {
if (cancelled) return; 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. // 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. // 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')}`); toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
setSelected({ id: sessionId, serverId: selectedRef.current?.serverId ?? null }); setSelected({ id: sessionId });
} }
})(); })();
return () => { return () => {
-146
View File
@@ -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;
}
+1 -13
View File
@@ -1,18 +1,6 @@
export { useGlobal } from './useGlobal'; export { useGlobal } from './useGlobal';
export { usePanelChannel } from './usePanelChannel'; export { usePanelChannel } from './usePanelChannel';
export { useClient, createClient, resolveBearerToken } from './useClient'; 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 { useDebounce } from './useDebounce';
export { useDragAndDrop } from './useDragAndDrop'; export { useDragAndDrop } from './useDragAndDrop';
export { useImageLoader } from './useImageLoader'; export { useImageLoader } from './useImageLoader';
+34 -57
View File
@@ -1,48 +1,29 @@
import { useGlobal } from './useGlobal'; import { useGlobal } from './useGlobal';
let theToken: string | null = null; let theToken: string | null = null;
/** export const createClient = (baseUrl: string = '/api') => {
* A client bound to one server. const lsToken =
* window.officerBearerToken ||
* `baseUrl`/`token` omitted means this origin with the session you signed in with — every existing document.body.dataset['officerBearerToken'] ||
* caller. Naming both is how a panel talks to ANOTHER Officer without any global being switched, which document.body.dataset['bearerToken'] ||
* is what lets two panes hold two live conversations on two machines (see `connections.ts`). new URL(window.location.href).searchParams.get('officerToken') ||
*/ localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
/** localStorage.getItem('BEARER_TOKEN') ||
* The session token for THIS origin, from every place one is allowed to live. sessionStorage.getItem('BEARER_TOKEN');
*
* 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();
theToken = lsToken; theToken = lsToken;
return { return {
baseUrl, baseUrl,
token: token ?? theToken, token: theToken,
get: <T>(url: string) => get<T>(url, baseUrl, token), get: <T>(url: string) => get<T>(url, baseUrl),
getText: (url: string) => getText(url, baseUrl, token), getText: (url: string) => getText(url, baseUrl),
getBlob: (url: string) => getBlob(url, baseUrl, token), getBlob: (url: string) => getBlob(url, baseUrl),
// getStream: (url) => getStream(url, baseUrl), // getStream: (url) => getStream(url, baseUrl),
post: <T>(url: string, payload?: any) => post<T>(url, payload, baseUrl, token), post: <T>(url: string, payload?: any) => post<T>(url, payload, baseUrl),
put: <T>(url: string, payload?: any) => put<T>(url, payload, baseUrl, token), put: <T>(url: string, payload?: any) => put<T>(url, payload, baseUrl),
patch: <T>(url: string, payload?: any) => patch<T>(url, payload, baseUrl, token), patch: <T>(url: string, payload?: any) => patch<T>(url, payload, baseUrl),
delete: <T>(url: string, payload?: any) => DELETE<T>(url, payload, baseUrl, token), delete: <T>(url: string, payload?: any) => DELETE<T>(url, payload, baseUrl),
}; };
}; };
@@ -56,15 +37,11 @@ export const useClient = (baseUrl: string = '/api') => {
return { ...client, apiError, setApiError }; return { ...client, apiError, setApiError };
}; };
export const getHeaders = (isText: boolean = false, token?: string | null) => { export const getHeaders = (isText: boolean = false) => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
// `token` names a specific server's credential; omitted falls back to this origin's session, which is if (theToken) {
// every existing caller. See `connections.ts` — a second server is reached by naming it, never by headers['Authorization'] = `Bearer ${theToken}`;
// 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'; headers['Content-Type'] = isText ? 'text/plain' : 'application/json';
@@ -83,8 +60,8 @@ const parseBody = async <T>(res: Response): Promise<T> => {
return (text ? JSON.parse(text) : undefined) as T; return (text ? JSON.parse(text) : undefined) as T;
}; };
export const getText = async (uri: string, baseUrl = '', token?: string | null) => { export const getText = async (uri: string, baseUrl = '') => {
const headers = getHeaders(true, token); const headers = getHeaders(true);
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers }); const res = await fetch(theUrl, { headers });
await validateResponse(res); await validateResponse(res);
@@ -92,16 +69,16 @@ export const getText = async (uri: string, baseUrl = '', token?: string | null)
return text; return text;
}; };
export const get = async <T>(uri: string, baseUrl = '', token?: string | null) => { export const get = async <T>(uri: string, baseUrl = '') => {
const headers = getHeaders(false, token); const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers }); const res = await fetch(theUrl, { headers });
await validateResponse(res); await validateResponse(res);
return parseBody<T>(res); return parseBody<T>(res);
}; };
export const getBlob = async (uri: string, baseUrl = '', token?: string | null) => { export const getBlob = async (uri: string, baseUrl = '') => {
const headers = { Authorization: `Bearer ${token ?? theToken!}` }; const headers = { Authorization: `Bearer ${theToken!}` };
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers }); const res = await fetch(theUrl, { headers });
await validateResponse(res); await validateResponse(res);
@@ -116,8 +93,8 @@ export const getBlob = async (uri: string, baseUrl = '', token?: string | null)
// return stream; // return stream;
// }; // };
export const post = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => { export const post = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers: Record<string, string> = getHeaders(false, token); const headers: Record<string, string> = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const body = payload instanceof FormData ? payload : JSON.stringify(payload); const body = payload instanceof FormData ? payload : JSON.stringify(payload);
if (payload instanceof FormData) { if (payload instanceof FormData) {
@@ -132,8 +109,8 @@ export const post = async <T>(uri: string, payload?: any, baseUrl = '', token?:
return parseBody<T>(res); return parseBody<T>(res);
}; };
export const put = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => { export const put = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers: any = getHeaders(false, token); const headers: any = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const body = payload instanceof FormData ? payload : JSON.stringify(payload); const body = payload instanceof FormData ? payload : JSON.stringify(payload);
@@ -150,8 +127,8 @@ export const put = async <T>(uri: string, payload?: any, baseUrl = '', token?: s
return parseBody<T>(res); return parseBody<T>(res);
}; };
export const patch = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => { export const patch = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers = getHeaders(false, token); const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { const res = await fetch(theUrl, {
method: 'PATCH', method: 'PATCH',
@@ -162,8 +139,8 @@ export const patch = async <T>(uri: string, payload?: any, baseUrl = '', token?:
return parseBody<T>(res); return parseBody<T>(res);
}; };
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => { export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers = getHeaders(false, token); const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { const res = await fetch(theUrl, {
method: 'delete', method: 'delete',
@@ -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)}`;
}
@@ -13,14 +13,6 @@ import type { ChatMessage } from '../Chat/types';
export type SelectedSession = { export type SelectedSession = {
id: string; 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; model?: string | null;
resumeSummary?: string; resumeSummary?: string;
resumeSessionId?: string; resumeSessionId?: string;
@@ -148,12 +140,10 @@ type NewChatProps = {
sessionCwd?: string | null; sessionCwd?: string | null;
sessionTitle?: string | null; sessionTitle?: string | null;
partCount?: number; partCount?: number;
/** Which Officer runs this conversation. Absent = the one that served this page. */
serverId?: string | null;
}; };
function NewChat(props: NewChatProps) { 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 location = useLocation();
const locationState = location.state as ChatLocationState; const locationState = location.state as ChatLocationState;
const { invalidate: invalidateClaudeSessions } = useClaudeSessions(); const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
@@ -171,8 +161,6 @@ function NewChat(props: NewChatProps) {
initialMessages, initialMessages,
onTurnComplete, onTurnComplete,
context: 'chat', 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. // Only the tail is loaded up front — let the chat page older messages upward on scroll.
paginate: paginate:
resumeSessionId && typeof total === 'number' 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 // — 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 // 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. // 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; 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 // 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 ( return (
<NewChat <NewChat
key={`${selected.serverId ?? 'local'}:${selected.id}`} key={selected.id}
resumeSummary={selected.resumeSummary} resumeSummary={selected.resumeSummary}
resumeSessionId={selected.resumeSessionId} resumeSessionId={selected.resumeSessionId}
initialMessages={selected.initialMessages} initialMessages={selected.initialMessages}
@@ -269,7 +257,6 @@ export const ChatDetailPanel = () => {
sessionCwd={selected.cwd} sessionCwd={selected.cwd}
sessionTitle={title} sessionTitle={title}
partCount={selected.partCount} partCount={selected.partCount}
serverId={selected.serverId}
/> />
); );
}; };
@@ -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 (
<button
type="button"
onClick={() => setAdding(true)}
title="Add another Officer server"
className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3 w-3" /> server
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-1">
<Chip label="This server" active={value === null} onClick={() => onChange(null)} />
{connections.map((connection) => (
<Chip
key={connection.id}
label={connection.name}
active={value === connection.id}
onClick={() => 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);
}}
/>
))}
<button
type="button"
onClick={() => setAdding(true)}
title="Add another Officer server"
className="cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3 w-3" />
</button>
{adding && <AddServerForm onDone={() => setAdding(false)} onAdded={(id) => onChange(id)} />}
</div>
);
};
const Chip = ({
label,
active,
onClick,
onRemove,
}: {
label: string;
active: boolean;
onClick: () => void;
onRemove?: () => void;
}) => (
<span
className={`group inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
active ? 'border-duck-teal/50 bg-duck-teal/10 text-duck-teal' : 'border-border text-muted-foreground'
}`}
>
<button type="button" onClick={onClick} className="cursor-pointer">
{label}
</button>
{onRemove && (
<button
type="button"
onClick={onRemove}
title={`Forget ${label}`}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</span>
);
/**
* 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<string | null>(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 (
<div className="mt-1 flex w-full flex-col gap-1 rounded-md border border-border bg-background/80 p-2">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Server className="h-3 w-3" /> Add an Officer server
</div>
<input
autoFocus
value={url}
onChange={(ev) => 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"
/>
<input
value={key}
onChange={(ev) => 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"
/>
<input
value={name}
onChange={(ev) => 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 && <div className="text-[11px] text-destructive">{error}</div>}
<div className="flex gap-1">
<button
type="button"
disabled={busy || !url.trim() || !key.trim()}
onClick={submit}
className="cursor-pointer rounded bg-duck-teal px-2 py-1 text-[11px] text-white disabled:opacity-40"
>
{busy ? 'Checking…' : 'Add'}
</button>
<button
type="button"
onClick={onDone}
className="cursor-pointer rounded px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
>
Cancel
</button>
</div>
</div>
);
};
@@ -6,7 +6,6 @@ import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, Rela
import { useSelectedChatSession } from '../../channels'; import { useSelectedChatSession } from '../../channels';
import { errorText } from 'helpers/error-text'; import { errorText } from 'helpers/error-text';
import { useClaudeSessions } from 'state/useClaudeSessions'; import { useClaudeSessions } from 'state/useClaudeSessions';
import { ServerChips } from './ServerChips';
import type { SelectedSession } from './ChatDetailPanel'; import type { SelectedSession } from './ChatDetailPanel';
import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
import { PwdSelector } from './PwdSelector'; 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 = // that session among its neighbours instead of snapping the list back to the default group. Null =
// the default general_chat_sessions dir. // the default general_chat_sessions dir.
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; 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<string | null>(selected?.serverId ?? null);
const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd, serverId);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null); const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -82,17 +76,7 @@ export const SessionList = () => {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-background/60 px-3 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-3 py-2">
<ServerChips
value={serverId}
onChange={(next) => {
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 });
}}
/>
<PwdSelector <PwdSelector
value={activeCwd} value={activeCwd}
onChange={(cwd) => { onChange={(cwd) => {
@@ -113,7 +97,7 @@ export const SessionList = () => {
onClick={() => { onClick={() => {
// A new chat starts in the group the list is showing, and says so in both places: on the // 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. // 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 }); navigate(chatNewPath(activeCwd), { replace: true });
}} }}
// Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and // 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. */} not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
<DataRow <DataRow
to={linkTo(session.id)} to={linkTo(session.id)}
// Stamp the machine onto the selection BEFORE the route changes. The resolver that
// fetches the transcript reads it from here — without it a remote row would be
// looked up on this origin, where that id names nothing (or, worse, names something
// else entirely, since two Officers can hold the same uuid).
onClick={() => setSelected({ id: session.id, cwd: session.cwd, title: session.title, serverId })}
title={session.title} title={session.title}
selected={isActive} selected={isActive}
className="min-w-0 flex-1" className="min-w-0 flex-1"
+6 -22
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket'; import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useServerClient, chatSocketUrl } from 'hooks/useServerClient'; import { useClient } from 'hooks/useClient';
import { resolveBearerToken } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
import { spliceRunningTasks } from '../apps/Chat/running-tasks'; 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. // When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in.
paginate?: { sessionId: string; total: number; initialOffset: number }; paginate?: { sessionId: string; total: number; initialOffset: number };
onTurnComplete?: (hadToolCalls: boolean) => void; 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, initialMessages: preloadedMessages,
paginate, paginate,
onTurnComplete, onTurnComplete,
serverId,
} = options ?? {}; } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []); const [messages, setMessages] = useState<ChatMessage[]>(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. // reattachable the moment the harness names its transcript rather than when the turn finishes.
const claudeSessionIdRef = useRef<string | null>(resumeSessionId ?? null); const claudeSessionIdRef = useRef<string | null>(resumeSessionId ?? null);
// The one string that makes this panel talk to another machine. Absent = this origin, which is every const client = useClient();
// 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 // 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. // 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; const hasMoreOlder = !!paginate && oldestOffset > 0;
// Null when a named server is unknown, which opens no socket rather than dialling this origin under const token = localStorage.getItem('BEARER_TOKEN');
// another server's name — the failure that would put one machine's turn in another's pane. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// The SAME resolution the HTTP client uses. Reading localStorage directly here meant a token held const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;
// 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());
function flushStreaming() { function flushStreaming() {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
@@ -545,7 +529,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}); });
}, [sendAttach]); }, [sendAttach]);
const { isConnected, send } = useChatWebSocket({ url: wsUrl ?? '', onMessage: handleMessage, onOpen }); const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });
sendRef.current = send; sendRef.current = send;
// `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands // `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands
+8 -15
View File
@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useServerClient } from 'hooks/useServerClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
const SESSIONS_KEY = 'CLAUDE_SESSIONS'; 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)}` : ''); const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : '');
/** The default /chat dir plus every directory that already has Claude sessions. */ /** The default /chat dir plus every directory that already has Claude sessions. */
export function useChatPwds(serverId?: string | null) { export function useChatPwds() {
const client = useServerClient(serverId); const client = useClient();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({ const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({
// Server-scoped: two machines have different working directories, and an unscoped key would show queryKey: ['CHAT_PWDS'],
// one machine's folders under the other's name. enabled: isAuthenticated,
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'), queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'),
staleTime: 30 * 1000, 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). */ /** Sessions for a working directory, read from Claude's own transcript store (source of truth). */
export function useClaudeSessions(cwd?: string | null, serverId?: string | null) { export function useClaudeSessions(cwd?: string | null) {
const client = useServerClient(serverId); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const q = cwdQuery(cwd); const q = cwdQuery(cwd);
const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ 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 — queryKey: [SESSIONS_KEY, cwd ?? 'default'],
// without it the cache hands one machine's conversation to the other, which looks like a UI glitch enabled: isAuthenticated,
// 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}`), queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
staleTime: 30 * 1000, staleTime: 30 * 1000,
}); });