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 outdc6b623and my token-resolution change with it — the latter first, because it was written against useServerClient, whichdc6b623introduced. 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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user