revert the chat tabs and panes work, back to one conversation

Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from
"tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the
per-server directory picker, the render-loop fix, pane transcript resolution, the send queue,
the two socket fixes from the other session, the pane-socket notes, and my own socket-set change
from tonight. He is rebuilding from here.

Deliberately KEPT: dc6b623, "talk to two officers at once from one browser". That was a separate
ask that predates the tabs one, and the multi-server client, the server chips and the connections
store stand on their own without panes. Reverting it too is one more command if that was the
intent.

Collateral, worth naming: cb7ab55 carried an unrelated MusicPlayerHost change alongside its
socket instrumentation, so that came out with it.

Reverts, not a reset — every one of these is pushed and a second session is live in this repo.

Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing
identically before this and unrelated to chat.

What is NOT explained by this revert: the browser symptoms tonight. The server was verified good
throughout — two real turns streamed back through the public URL on both models, and the full
2,281-message history came through nginx intact. Whatever the client fault is, it is still
unfound, and the pre-tabs code is where it now has to be looked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:29:18 +01:00
co-authored by Claude Opus 5
parent 7726c9fc71
commit 0a4ff548b9
19 changed files with 77 additions and 749 deletions
-74
View File
@@ -1,74 +0,0 @@
# Multi-pane chat: where it stands, and the one open bug
Written 2026-08-10 at the end of a long session, so the next one starts from evidence instead of
re-deriving it.
## The open bug
**A pane pointed at a REMOTE server shows `Disconnected` and never gets a reply.** The pane opens, lists
and reads that server's conversations fine (HTTP with the API key works). Sending produces nothing.
Console, repeatedly:
```
WebSocket connection to 'wss://officer.pastilhas.dev/api/chat/ws?token=ofk_…' failed:
WebSocket is closed before the connection is established.
```
The same message also appears for the LOCAL pane's `ws://localhost:9010/...`, yet the local pane works
and shows connected. So the message alone is not the bug — something closes the remote one and it never
comes back.
## Ruled out, each by direct test — do not re-test these
- **The server.** `wss://officer.pastilhas.dev/api/chat/ws?token=<the same ofk_ key>` opens on the first
try from outside the browser. Tested twice: with no `Origin` header, and with
`Origin: http://localhost:9010` (what the browser sends). Both `OPEN`.
- **The key.** Same key, works over HTTP for `/chat/sessions`, and opens the socket above.
- **Websocket auth not understanding API keys.** `upgradeWs` in `server.tsx` uses `resolveAuthToken`, the
same resolver as the HTTP doors, and comments say so explicitly. `chat` is an `execution` capability
and the owner passes `isWsProviderAllowed`.
- **Officer being stale on alpha.** Pulled and restarted; the failure persists.
- **Send being dropped.** Fixed in `243bd04``useChatWebSocket.send` used to `return` silently when the
socket was not `OPEN`. It now queues and flushes on open. That fix is real and worth keeping, but it
did not resolve this: a socket that never opens never flushes.
## Where to look next
`src/workspaces/hooks/src/useChatWebSocket.ts`, and specifically what happens with SEVERAL instances
mounted at once — one per pane.
The handlers look individually correct: `close` returns early when `socketRef.current !== socket`, the
effect depends on `[url]` alone, and `isCleaningUpRef` is reset on mount. What has NOT been established
is how those interact across three simultaneous instances plus React's dev StrictMode double-invoke,
which creates, closes and recreates every socket on mount.
Concrete things to try, cheapest first:
1. **Instrument before theorising.** Log `url`, `readyState` and instance identity on every create,
open, close and retry. The console message says a close arrived during CONNECTING; it does not say
who called it. That is the whole question and it is one log line away.
2. **Check whether the effect re-runs.** `url` is a string, so it should be stable — but
`chatSocketUrl(serverId, localStorage.getItem('BEARER_TOKEN'))` is recomputed every render, and if
anything makes `serverId` flip (say, a pane re-resolving its target) the URL changes and the socket
is torn down mid-handshake, forever.
3. **Suspect the transcript resolver in `ChatPane`.** It calls `onTargetChange` on success, which
rewrites the tab state, which produces a new `target` object for every pane in that tab. If that ever
loops, every pane's socket is rebuilt on every pass and none survives the handshake.
4. **Try one pane pointed at the remote server, alone.** If it connects, the bug is about multiplicity
rather than about the remote server, which would be the single most useful fact to have.
## What works, so it is not re-litigated
- Adding a second server by URL + `ofk_` key, verified against `/api/auth/me` before storing.
- Per-pane server chips; the list, the directory picker and the transcript all follow the pane's server.
- Tabs, splitting to three panes, closing, renaming a tab, and the tab name winning the page title.
- Reading a remote conversation end to end.
## Setup facts worth not rediscovering
- Andre's Mac key lives in agent memory. Alpha needs its own; the one in the console above is alpha's.
- `bun dev` on the Mac serves a STALE bundle whenever a new file is added to a workspace package — the
symptom is `X is not a function` for an export that plainly exists. Restart it; do not debug it.
- The Mac is `https://macbook.pastilhas.dev` through NPM on alpha (`100.64.0.1` → Mac `100.64.0.9:9010`),
and the cert is valid under strict TLS.
@@ -1,12 +1,15 @@
import { useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import type { SelectedSession } from 'officerdev';
import { ChatTabs, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
import type { LayoutNode, SelectedSession } from 'officerdev';
import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
import { toast } from '@/components/ui/sonner';
import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient';
import { serverClient } from 'hooks/useServerClient';
import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { defaultLayout, hasAppType } from './defaultLayout';
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20;
@@ -25,7 +28,26 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
selectedRef.current = selected;
// A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no
// longer resolves; `appTypes` lands anything unknown on the detail panel.
const workspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
const isMobile = useIsMobile();
const navigate = useNavigate();
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
// Adopt a structural change to this screen's layout.
//
// `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened
// /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs
// which app a panel runs, never the tree, so adding the Live panel above the list would have been
// invisible to every existing user and visible only on a fresh account.
//
// Replacing outright is safe *here* specifically because the screen is `locked`: its structure is
// dictated by code and the only thing a user can have contributed is the column sizes, which is a
// cheap thing to lose once. Terminates because the replacement contains the panel it tests for.
useEffect(() => {
if (!workspace.isLoaded) return;
if (hasAppType(workspace.value, 'chat-live')) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
@@ -98,15 +120,20 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isNew, groupCwd]);
// The tabbed, multi-pane chat replaces the fixed three-panel workspace. The panels themselves are
// unchanged and still registered for the dashboard; what changes is that a PANE owns its conversation
// rather than the whole screen sharing one, which is what lets two machines be live side by side.
//
// `useDashboardState`/`WorkspaceView` are no longer used here. The layout that matters now is the tab
// blob in localStorage, because a tab spanning two servers cannot be stored per server.
return (
<div className="h-full w-full pt-2">
<ChatTabs />
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => {
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
// on /chat/<id> it isn't (deliberately — see chat-routes.ts), so fall back to the open
// session's own directory, which the resolve above put on the selection.
if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true });
}}
/>
</div>
);
};
+2 -6
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import type { PageTitleOverride } from 'officerdev';
import { usePageTitleOverride, useChatTabName } from 'officerdev';
import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
type TitleRule = { match: (p: string) => boolean; title: string };
@@ -160,7 +160,6 @@ claimTabIdentity();
export function usePageTitle() {
const { pathname } = useLocation();
const override = usePageTitleOverride();
const chatTabName = useChatTabName();
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
@@ -186,10 +185,7 @@ export function usePageTitle() {
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
// A chat tab's own name is the most specific thing anyone has said about this page — more specific
// than the conversation inside it (there may be three) and more deliberate than a browser-tab name
// typed earlier on a different screen. So it wins outright.
return [chatTabName ?? label ?? override?.title ?? titleForPath(pathname), rename] as const;
return [label ?? override?.title ?? titleForPath(pathname), rename] as const;
}
/**
+4 -14
View File
@@ -24,7 +24,7 @@ class SessionManager {
cwd,
model,
piProcess: null,
sockets: new Set(),
ws: null,
lastActivity: Date.now(),
idleTimer: null,
streamBuffer: '',
@@ -145,7 +145,7 @@ class SessionManager {
attachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.sockets.add(ws);
session.ws = ws;
session.lastActivity = Date.now();
if (session.idleTimer) {
@@ -155,24 +155,14 @@ class SessionManager {
}
}
/**
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
* session's only socket field, so a stale client's close event silenced whichever client had attached
* after it. A close is only the end of the conversation when nothing else is still watching.
*/
detachWs(sessionId: string, ws: any): void {
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.sockets.delete(ws);
session.ws = null;
session.lastActivity = Date.now();
}
}
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
hasSockets(sessionId: string): boolean {
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
}
setIdleTimeout(sessionId: string, timeoutMs: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
+1 -7
View File
@@ -299,13 +299,7 @@ export type UserSession = {
cwd: string;
model: string;
piProcess: any | null;
/**
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
* of them closing set it to null and killed delivery for the rest.
*/
sockets: Set<any>;
ws: any | null;
lastActivity: number;
idleTimer: Timer | null;
streamBuffer: string;
+9 -15
View File
@@ -158,10 +158,8 @@ export function close(ws: ServerWebSocket<WSData>): void {
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {
sessionManager.detachWs(sessionId, ws);
// Only once nothing is watching. Another pane or another device still attached means the
// conversation is live, and arming the idle GC here would collect it out from under them.
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
sessionManager.detachWs(sessionId);
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
}
}
@@ -246,7 +244,7 @@ function createMessageHandler(sessionId: string, model: string) {
const session = sessionManager.getSession(sessionId);
if (!session) return;
foldIntoSession(session, msg, model);
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
};
}
@@ -653,7 +651,7 @@ async function handleResumeCursor(
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
// away" into a turn that was running perfectly well.
if (msg.generating && decision.kind !== 'assume') {
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
await endTurnIfAgentIsGone(ws, sessionId, decision.model);
}
}
@@ -823,7 +821,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
* so its sessions are left alone rather than guessed at.
*/
async function endTurnIfAgentIsGone(
targets: Iterable<ServerWebSocket<WSData> | null>,
ws: ServerWebSocket<WSData> | null,
sessionId: string,
model: string,
): Promise<void> {
@@ -836,11 +834,11 @@ async function endTurnIfAgentIsGone(
const event: ServerMessage = { type: 'cut-off' };
try {
const seq = await appendChatEvent(sessionId, event);
for (const target of targets) sendToClient(target, event, seq);
sendToClient(ws, event, seq);
} catch (err) {
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
for (const target of targets) sendToClient(target, event);
sendToClient(ws, event);
}
logger.info('Ended a turn whose agent had gone', { sessionId });
}
@@ -852,11 +850,7 @@ async function endTurnIfAgentIsGone(
sidecar.onClaudeSidecarStarted(() => {
for (const session of sessionManager.getAllSessions()) {
if (!session.isGenerating) continue;
void endTurnIfAgentIsGone(
session.sockets as Set<ServerWebSocket<WSData>>,
session.sessionId,
session.model,
);
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
}
});
+7 -79
View File
@@ -20,22 +20,6 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
/**
* Messages typed before the socket was ready.
*
* `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so
* pressing enter did nothing and the turn never happened. That window is not rare. React's dev
* StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a
* reconnect after a drop reopens it again; with several chat panes on screen there are several sockets
* doing this at once. One of them is always briefly not OPEN.
*
* Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this
* reason — the composer is allowed to fire before the transport is ready.
*/
const pendingRef = useRef<string[]>([]);
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
const closeTimerRef = useRef<number | null>(null);
const connect = () => {
if (isCleaningUpRef.current) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
@@ -43,30 +27,15 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
const socket = new WebSocket(url);
socketRef.current = socket;
// Temporary, and deliberately loud. A pane connected and then sat silent, and reasoning from this
// hook's source three times running did not explain it — the console says a socket closed, never who
// closed it or whether the message went out. `window.__officerWs = false` turns it off.
const host = new URL(url).host;
const log = (what: string, extra?: unknown) =>
(window as any).__officerWs !== false && console.log(`[ws ${host}] ${what}`, extra ?? '');
log('creating');
socket.addEventListener('open', () => {
if (socketRef.current !== socket) return;
setIsConnected(true);
retryRef.current = 0;
log('OPEN');
// BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user
// typed while connecting belongs after that, not in front of it.
const queued = pendingRef.current;
pendingRef.current = [];
onOpenRef.current?.();
for (const message of queued) socket.send(message);
});
socket.addEventListener('message', (ev) => {
try {
log('recv', String(ev.data).slice(0, 120));
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
onMessageRef.current(data);
} catch {
@@ -74,13 +43,7 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
}
});
socket.addEventListener('close', (ev) => {
log('close', {
code: ev.code,
reason: ev.reason,
stale: socketRef.current !== socket,
tearingDown: isCleaningUpRef.current,
});
socket.addEventListener('close', () => {
if (isCleaningUpRef.current) return;
if (socketRef.current !== socket) return;
setIsConnected(false);
@@ -96,60 +59,25 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
};
useEffect(() => {
// A pending teardown from a remount that is about to be undone — see below.
if (closeTimerRef.current !== null) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
isCleaningUpRef.current = false;
connect();
return () => {
/**
* Close LATER, not now.
*
* Closing here directly is correct for a real unmount and disastrous for a remount, and this hook
* cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect
* (mount → unmount → mount), and a pane whose subtree is re-created — a tab re-render, a resolved
* transcript, a parent key change — does the same. Each time, the socket was closed while still
* CONNECTING, the browser logged "closed before the connection is established", and the replacement
* was closed in turn. A pane could churn forever and never hold a connection: exactly what a fresh
* remote pane did.
*
* Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and
* cancels this timer, so the live socket is kept and the handshake completes. A real unmount has
* nobody to cancel it and the socket closes a frame later, which costs nothing.
*/
isCleaningUpRef.current = true;
if (retryTimeoutRef.current !== null) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
const socket = socketRef.current;
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it
if (socket) socket.close();
if (socketRef.current === socket) socketRef.current = null;
}, 0);
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
};
}, [url]);
const send = (data: Record<string, unknown>) => {
const socket = socketRef.current;
const message = JSON.stringify(data);
if (socket && socket.readyState === WebSocket.OPEN) {
if ((window as any).__officerWs !== false) console.log(`[ws ${new URL(url).host}] send`, message.slice(0, 120));
socket.send(message);
return;
}
if ((window as any).__officerWs !== false)
console.log(`[ws ${new URL(url).host}] QUEUED (socket ${socket?.readyState ?? 'none'})`, message.slice(0, 80));
// Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a
// socket that never comes back cannot grow this without limit; the oldest go first, because the
// newest message is the one the user is still waiting on.
pendingRef.current.push(message);
if (pendingRef.current.length > 50) pendingRef.current.shift();
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(data));
};
return { isConnected, send };
@@ -119,16 +119,7 @@ export const MusicPlayerHost = () => {
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
//
// DISABLED on the web (Andre, 2026-08-10). The music sidecar is not running on every machine that
// serves this app, so every page load fired `/music/now-playing` and logged a 503 in the console of a
// browser that was not there for music at all. Restoring a paused track is a nicety; a permanent error
// on every load of every screen is not worth it. The player itself is untouched — play something and
// it works; it simply no longer asks what WAS playing.
const RESTORE_NOW_PLAYING = false;
useEffect(() => {
if (!RESTORE_NOW_PLAYING) return;
if (restoredRef.current) return;
restoredRef.current = true;
if (queue.length) return;
@@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router';
import { Unplug } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { EditableTitle } from '@/components/EditableTitle';
import { usePaneSelection } from './PaneSelection';
import { useSelectedChatSession } from '../../channels';
import { usePublishPageTitle } from '../../page-title';
import { useAuth } from 'hooks/useAuth';
import { errorText } from 'helpers/error-text';
@@ -226,7 +226,7 @@ function NewChat(props: NewChatProps) {
}
export const ChatDetailPanel = () => {
const [selected] = usePaneSelection();
const [selected] = useSelectedChatSession();
// Name the page after the conversation, whenever the URL names a real one. Gated on the route param
// rather than on `selected`, so `/chat` and `/chat/new` keep the plain "Chat" — the panel holds a
@@ -1,123 +0,0 @@
import { useEffect, useRef } from 'react';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { serverClient } from 'hooks/useServerClient';
import { connectionLabel } from 'hooks/connections';
import { SessionList } from './SessionList';
import { ChatDetailPanel } from './ChatDetailPanel';
import type { SelectedSession } from './ChatDetailPanel';
import { PaneSelectionProvider } from './PaneSelection';
/**
* One self-contained conversation column: its own server, its own list, its own chat.
*
* Modelled on the mobile app's pane, where "a pane is just a whole ChatScreen" — an empty one IS the
* conversation list, and filling it is tapping a row. That is what makes two panes independent without
* inventing a second concept: everything a conversation needs is already inside one.
*
* The web version differs in one way, deliberately. Mobile has room for a list and a chat side by side
* inside a pane; two or three of those in a browser column would leave nothing for the conversation. So
* a pane shows its LIST until something is open and the CHAT afterwards, with one way back. The tab bar
* above holds the panes; this holds one conversation.
*/
type ChatPaneProps = {
target: SelectedSession | null;
onTargetChange: (next: SelectedSession | null) => void;
/** Shown when more than one pane is open, so it is obvious which machine a column is on. */
showServerBadge?: boolean;
};
export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => {
const open = !!target;
/**
* Load the transcript for a row this pane just opened.
*
* The screen-level resolver does this for the single-panel layout, but it writes to the shared
* channel — which a pane deliberately does not read. So a pane clicked a row, got `{id, title, cwd}`
* and nothing else, and rendered an empty conversation while the list behind it reset to the default
* directory. It has to resolve its own, from ITS server: two machines can hold the same uuid, so
* asking the wrong one is not merely empty, it is wrong.
*/
const resolvingRef = useRef<string | null>(null);
const id = target?.id ?? null;
const needsTranscript = !!id && !id.startsWith('new:') && !target?.resumeSessionId;
const serverId = target?.serverId ?? null;
useEffect(() => {
if (!needsTranscript || !id) return;
if (resolvingRef.current === id) return; // one fetch per row, not one per render
resolvingRef.current = id;
let cancelled = false;
(async () => {
try {
const detail = await serverClient(serverId).get<{
model?: string | null;
messages: unknown[];
total: number;
offset: number;
cwd: string;
title?: string | null;
partCount?: number;
}>(`/chat/sessions/${id}?limit=20`);
if (cancelled) return;
onTargetChange({
id,
serverId,
model: detail.model,
resumeSessionId: id,
initialMessages: detail.messages as never,
total: detail.total,
initialOffset: detail.offset,
cwd: detail.cwd,
title: detail.title ?? undefined,
partCount: detail.partCount,
});
} catch {
// Leave the pane on the row it has. Falling back to an empty chat would look like a conversation
// that lost its history rather than one that could not be read.
if (!cancelled) resolvingRef.current = null;
}
})();
return () => {
cancelled = true;
};
}, [needsTranscript, id, serverId, onTargetChange]);
return (
<PaneSelectionProvider value={target} onChange={onTargetChange}>
<div className="flex h-full min-w-0 flex-col">
{open && (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-2 py-1">
<button
type="button"
onClick={() => onTargetChange(null)}
title="Back to the conversation list"
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"
>
<ArrowLeft className="h-3 w-3" /> List
</button>
{showServerBadge && (
// Which machine this column is talking to. Only worth the space when there is more than
// one pane — with a single column the chips in the list already say it.
<span className="truncate rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{connectionLabel(target?.serverId)}
</span>
)}
</div>
)}
<div className="min-h-0 flex-1">
{!open ? (
<SessionList />
) : needsTranscript ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<ChatDetailPanel />
)}
</div>
</div>
</PaneSelectionProvider>
);
};
@@ -1,268 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Columns2, Plus, X } from 'lucide-react';
import { connectionLabel } from 'hooks/connections';
import { usePublishChatTabName } from '../../page-title';
import { ChatPane } from './ChatPane';
import type { SelectedSession } from './ChatDetailPanel';
/**
* Tabs of side-by-side conversations, each pane free to sit on a different Officer.
*
* This is the iPad layout brought to the browser: one window, one `/chat`, several live conversations
* on several machines at once. The mobile app proved the shape — what it adds over the old single-panel
* screen is that a pane owns its conversation (see `PaneSelection`) instead of the whole screen sharing
* one.
*
* ## What is stored where, which is the part that matters
*
* The layout — which tabs exist, which panes they hold, and what each pane has open — is kept in ONE
* unscoped `localStorage` entry, deliberately not per server. A tab holding one conversation from the
* laptop and one from alpha belongs to neither, so scoping it to either would be wrong. The mobile app
* makes the same call and says so.
*
* A pane's `target` carries its own `serverId`, so a restored tab reopens the right conversation on the
* right machine rather than looking it up on whichever server happens to be nearest.
*
* ## The URL
*
* `/chat/<id>` still deep-links, and still opens in the FIRST pane. It cannot mean more than that: with
* three conversations on screen there is no single "the" conversation for the address bar to name, which
* is the one place this design gives something up. Everything else about the route conventions holds.
*/
type Pane = { key: string; target: SelectedSession | null };
type Tab = { key: string; title?: string; panes: Pane[] };
const STORE_KEY = 'officer.chat.tabs.v1';
const MAX_PANES = 3;
let seq = 0;
const nextKey = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${seq++}`;
function load(): Tab[] {
try {
const raw = localStorage.getItem(STORE_KEY);
const parsed = raw ? (JSON.parse(raw) as Tab[]) : null;
if (!Array.isArray(parsed) || !parsed.length) throw new Error('empty');
// Keys were minted by a previous page whose counter restarted at zero. Re-mint them, or React can
// reuse the wrong subtree and a conversation appears in the wrong column — the mobile app hit
// exactly this and guards it the same way.
return parsed.map((tab) => ({
...tab,
key: nextKey('tab'),
panes: (tab.panes ?? []).slice(0, MAX_PANES).map((pane) => ({ ...pane, key: nextKey('pane') })),
}));
} catch {
return [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }];
}
}
export const ChatTabs = () => {
const [tabs, setTabs] = useState<Tab[]>(load);
const [activeKey, setActiveKey] = useState<string>(() => '');
const [renamingKey, setRenamingKey] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const restored = useRef(false);
// Read inside the stable callback above, so it never has to be a dependency.
const activeKeyRef = useRef(activeKey);
activeKeyRef.current = activeKey;
// First render picks the first tab; afterwards the user owns it.
useEffect(() => {
if (restored.current) return;
restored.current = true;
setActiveKey(tabs[0]?.key ?? '');
}, [tabs]);
useEffect(() => {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(tabs));
} catch {
/* private mode or quota — the layout still works for this page's lifetime */
}
}, [tabs]);
const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0];
// Only a name YOU typed is published — a label derived from the conversation would just restate the
// title the chat already publishes, one tier lower, and would then outrank a browser-tab name for no
// reason the user could see.
usePublishChatTabName(active?.title?.trim() || null);
const update = (tabKey: string, fn: (tab: Tab) => Tab) =>
setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab)));
const addTab = () => {
const tab: Tab = { key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] };
setTabs((prev) => [...prev, tab]);
setActiveKey(tab.key);
};
const closeTab = (tabKey: string) => {
setTabs((prev) => {
const next = prev.filter((tab) => tab.key !== tabKey);
// Never leave nothing: an empty tab bar has no way back to a conversation.
const safe = next.length ? next : [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }];
if (tabKey === activeKey) setActiveKey(safe[0]!.key);
return safe;
});
};
const startRename = (tab: Tab) => {
setRenamingKey(tab.key);
// Seeded with the typed name only, not the derived label: pre-filling a name the user never chose
// makes Enter silently adopt it as if they had.
setRenameValue(tab.title ?? '');
};
const commitRename = () => {
if (!renamingKey) return;
const next = renameValue.trim();
// Empty hands the tab back to its derived name — the only way out, and no third state.
update(renamingKey, (tab) => ({ ...tab, title: next || undefined }));
setRenamingKey(null);
};
const splitPane = () =>
active &&
update(active.key, (tab) =>
tab.panes.length >= MAX_PANES ? tab : { ...tab, panes: [...tab.panes, { key: nextKey('pane'), target: null }] },
);
const closePane = (paneKey: string) =>
active &&
update(active.key, (tab) =>
tab.panes.length <= 1 ? tab : { ...tab, panes: tab.panes.filter((pane) => pane.key !== paneKey) },
);
// Stable across renders on purpose. It is handed to every pane as `onChange`, and a pane passes it
// into a context that other components read — an identity that changed every render would make any
// effect depending on it re-run forever, which is the render loop this file already caused once.
const setPaneTarget = useCallback(
(paneKey: string, target: SelectedSession | null) =>
setTabs((prev) =>
prev.map((tab) =>
tab.key !== activeKeyRef.current
? tab
: { ...tab, panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)) },
),
),
[],
);
if (!active) return null;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Always visible: it is the only way to open a second tab or split a pane, so hiding it in the
single-conversation case would hide the feature from anyone who has not already used it. */}
{
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-background/60 px-2 py-1">
{tabs.map((tab) => {
// A tab is named after what is in it: the first pane's conversation, else the machine.
const first = tab.panes[0]?.target;
const label =
tab.title ||
first?.title ||
(tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat'));
if (renamingKey === tab.key) {
return (
<input
key={tab.key}
autoFocus
value={renameValue}
onChange={(ev) => setRenameValue(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commitRename();
if (ev.key === 'Escape') setRenamingKey(null);
}}
onBlur={commitRename}
aria-label="Tab name"
placeholder={label}
className="w-32 shrink-0 rounded-t border-b border-primary/40 bg-muted px-2 py-1 text-xs outline-none"
/>
);
}
return (
<button
key={tab.key}
type="button"
onClick={() => (tab.key === active.key ? startRename(tab) : setActiveKey(tab.key))}
onDoubleClick={() => startRename(tab)}
title={tab.key === active.key ? 'Click to rename' : label}
className={`group flex max-w-[14rem] shrink-0 items-center gap-1 rounded-t px-2 py-1 text-xs transition-colors ${
tab.key === active.key
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
}`}
>
<span className="truncate">{label}</span>
{tabs.length > 1 && (
<span
role="button"
tabIndex={-1}
onClick={(ev) => {
ev.stopPropagation();
closeTab(tab.key);
}}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100"
>
<X className="h-3 w-3" />
</span>
)}
</button>
);
})}
<button
type="button"
onClick={addTab}
title="New tab"
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={splitPane}
disabled={active.panes.length >= MAX_PANES}
title={
active.panes.length >= MAX_PANES ? `At most ${MAX_PANES} panes` : 'Split — add a pane beside this one'
}
className="ml-auto shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<Columns2 className="h-3.5 w-3.5" />
</button>
</div>
}
<div className="flex min-h-0 flex-1">
{active.panes.map((pane, index) => (
<div
key={pane.key}
className={`relative min-w-0 flex-1 ${index > 0 ? 'border-l border-border' : ''}`}
style={{ width: `${100 / active.panes.length}%` }}
>
{active.panes.length > 1 && (
<button
type="button"
onClick={() => closePane(pane.key)}
title="Close this pane"
className="absolute right-1 top-1 z-10 cursor-pointer rounded p-0.5 text-muted-foreground opacity-40 transition-opacity hover:bg-muted hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
)}
<ChatPane
target={pane.target}
onTargetChange={(next) => setPaneTarget(pane.key, next)}
showServerBadge={active.panes.length > 1}
/>
</div>
))}
</div>
</div>
);
};
@@ -8,14 +8,12 @@ type DirPickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (absPath: string) => void;
/** Whose filesystem to browse. Absent = this origin. */
serverId?: string | null;
};
// A simplified file-browser modal for picking a working directory (returns an absolute path).
// Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field.
export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerModalProps) => {
const api = useFilesAPI('home', serverId);
export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
const api = useFilesAPI('home');
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
@@ -25,9 +23,7 @@ export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerM
const [showHidden, setShowHidden] = useState(false);
const { data, isLoading, refetch } = useQuery({
// Server in the key: two machines have different trees, and without it one machine's folders
// are served from cache under the other's name.
queryKey: ['dir-picker', path, serverId ?? null],
queryKey: ['dir-picker', path],
queryFn: () => api.listDir(path),
enabled: open,
});
@@ -1,66 +0,0 @@
import { createContext, useContext, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useSelectedChatSession } from '../../channels';
import type { SelectedSession } from './ChatDetailPanel';
/**
* Which conversation THIS pane has open.
*
* `chat:selected-session` is one channel for the whole screen, which was right while there was exactly
* one conversation on it. Two panes side by side make it wrong: both would read the same value and show
* the same chat, which is the opposite of the point.
*
* So a pane provides its own state here, and `usePaneSelection` prefers it. Outside a pane the context
* is absent and the channel is used exactly as before — every existing caller (the mobile layout, the
* dashboard's own chat panel) is untouched, which is what makes this safe to drop in.
*
* Deliberately a context rather than props: `SessionList` and `ChatDetailPanel` sit at different depths
* and neither should have to know whether it is inside a pane.
*/
type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null) => void];
const PaneSelectionContext = createContext<PaneSelectionValue | null>(null);
/**
* Is this component inside a pane?
*
* A pane owns its conversation AND its directory, so it must not take either from the address bar:
* three panes cannot share one URL. Outside a pane the route stays the authority, exactly as before.
*/
export function useIsInPane(): boolean {
return useContext(PaneSelectionContext) !== null;
}
export function usePaneSelection(): PaneSelectionValue {
const scoped = useContext(PaneSelectionContext);
const channel = useSelectedChatSession();
// Hooks must run unconditionally, so the channel is always read; the scoped value simply wins.
return scoped ?? ([channel[0], channel[1]] as PaneSelectionValue);
}
/**
* Give the subtree its own selection.
*
* `value`/`onChange` make it controllable, so the tab shell can persist a pane's open conversation
* across a reload — the mobile app keeps the target on the pane for the same reason, and it is what
* makes a restored tab still point at the right chat on the right machine.
*/
export const PaneSelectionProvider = ({
children,
value,
onChange,
}: {
children: ReactNode;
value?: SelectedSession | null;
onChange?: (next: SelectedSession | null) => void;
}) => {
const [internal, setInternal] = useState<SelectedSession | null>(null);
const controlled = value !== undefined && !!onChange;
const pair = useMemo<PaneSelectionValue>(
() => (controlled ? [value ?? null, onChange!] : [internal, setInternal]),
[controlled, value, onChange, internal],
);
return <PaneSelectionContext.Provider value={pair}>{children}</PaneSelectionContext.Provider>;
};
@@ -6,16 +6,14 @@ import { DirPickerModal } from './DirPickerModal';
type PwdSelectorProps = {
value: string | null; // null = the default general_chat_sessions dir
onChange: (cwd: string | null) => void;
/** Which Officer's directories to offer. Absent = this origin. */
serverId?: string | null;
};
// A shorter, friendlier label for a working directory.
const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~');
const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd;
export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) => {
const { pwds, defaultCwd } = useChatPwds(serverId);
export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
const { pwds, defaultCwd } = useChatPwds();
const [open, setOpen] = useState(false);
const [browse, setBrowse] = useState(false);
const [custom, setCustom] = useState('');
@@ -98,7 +96,7 @@ export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) =>
</>
)}
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} serverId={serverId} />
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} />
</div>
);
};
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data';
import { usePaneSelection, useIsInPane } from './PaneSelection';
import { useSelectedChatSession } from '../../channels';
import { errorText } from 'helpers/error-text';
import { useClaudeSessions } from 'state/useClaudeSessions';
import { ServerChips } from './ServerChips';
@@ -19,16 +19,11 @@ export const SessionList = () => {
// them there rather than from the selection channel means the highlight and the group are correct on
// a deep link and on back/forward, before any panel has published.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
// Scoped to this pane when inside one, the shared channel otherwise — see PaneSelection.
const [selected, setSelected] = usePaneSelection();
const [selected, setSelected] = useSelectedChatSession();
// A group path when we're on one; otherwise the open session's own directory, so /chat/<id> shows
// that session among its neighbours instead of snapping the list back to the default group. Null =
// the default general_chat_sessions dir.
// In a pane the directory is the pane's, not the route's — three panes cannot share one URL, and
// letting the address bar win is what reset this list to the default the moment a row was clicked.
const inPane = useIsInPane();
const [paneCwd, setPaneCwd] = useState<string | null>(null);
const activeCwd = inPane ? paneCwd : (cwdFromSplat(splat) ?? selected?.cwd ?? null);
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null;
// Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another
// machine is the entire point, and a shared "current server" would make that impossible to express.
@@ -92,20 +87,17 @@ export const SessionList = () => {
value={serverId}
onChange={(next) => {
setServerId(next);
setPaneCwd(null);
// 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);
if (!inPane) navigate(chatListPath(null), { replace: true });
navigate(chatListPath(null), { replace: true });
}}
/>
<PwdSelector
serverId={serverId}
value={activeCwd}
onChange={(cwd) => {
setSelected(null); // sessions belong to a cwd — clear the open one when switching
if (inPane) setPaneCwd(cwd);
else navigate(chatListPath(cwd), { replace: true });
navigate(chatListPath(cwd), { replace: true });
}}
/>
<div className="ml-auto flex items-center gap-1.5">
@@ -212,7 +204,7 @@ export const SessionList = () => {
{/* The row is the link and the actions are its siblings — a <button> inside an <a> is
not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
<DataRow
to={inPane ? undefined : 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
@@ -7,9 +7,6 @@ import { ChatDetailPanel } from './ChatDetailPanel';
export { SessionList };
export { LiveSessions };
export { ChatDetailPanel };
export { ChatTabs } from './ChatTabs';
export { ChatPane } from './ChatPane';
export { PaneSelectionProvider, usePaneSelection } from './PaneSelection';
export type { SelectedSession } from './ChatDetailPanel';
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
@@ -1,17 +1,9 @@
import { useClient, getHeaders } from 'hooks/useClient';
import { useServerClient } from 'hooks/useServerClient';
const API_URL = '/api';
/**
* `serverId` names another Officer; absent is this origin, which is every existing caller.
*
* Load-bearing for the chat directory pickers: a path only means something on the machine it came from,
* so browsing without it showed THIS server's folders while the pane was pointed at another the exact
* bug the mobile app has with `browseDirectories`.
*/
export const useFilesAPI = (root: string = 'home', serverId?: string | null) => {
const client = useServerClient(serverId);
export const useFilesAPI = (root: string = 'home') => {
const client = useClient();
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
const withRoot = (url: string) =>
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
+2 -2
View File
@@ -1,7 +1,7 @@
export * from './hooks';
export * from './channels';
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
export { usePageTitleOverride, usePublishPageTitle, useChatTabName, usePublishChatTabName } from './page-title';
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
export * from './WidgetRegistry';
@@ -29,7 +29,7 @@ export {
} from './apps/Chat';
export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
export * from './apps/Chat/types';
export { SessionList, ChatDetailPanel, ChatTabs } from './apps/ChatHistory';
export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
@@ -53,39 +53,3 @@ export function usePublishPageTitle(override: PageTitleOverride | null) {
return () => setOverrideRef.current(null);
}, [id, title]);
}
// ── The chat tab's own name, which outranks everything ──
//
// `/chat` can hold several tabs, each with several conversations. A name you type on a chat tab is the
// most specific statement anyone has made about what this page is: more specific than the conversation
// showing inside it (there may be three), and more deliberate than a browser-tab name typed earlier for
// a different screen.
//
// So it sits ABOVE both in `usePageTitle`: chatTabName ?? label ?? override ?? route. Clearing it hands
// the page back to the chain below, which is the only way out and leaves no third state to get stuck in.
const CHAT_TAB_NAME = 'CHAT_TAB_NAME';
/** The active chat tab's typed name, or null. For the shell. */
export const useChatTabName = () => useGlobal<string | null>(CHAT_TAB_NAME, null)[0];
/**
* Publish the active chat tab's name. `null` when it has none, or when leaving /chat.
*
* Cleared on unmount for the same reason the override is: navigating away leaves nothing to reset it,
* and a leftover name is a header calling every other screen by the chat tab you last had open.
*/
export function usePublishChatTabName(name: string | null) {
const [, setName] = useGlobal<string | null>(CHAT_TAB_NAME, null);
// Through a ref, and depending on the STRING only — exactly as `usePublishPageTitle` does, for the
// reason documented there: `useGlobal` rebuilds its setter every render, so naming it as a dependency
// re-runs this effect on every render, which sets global state, which renders again. That is React
// error #185, and it took the whole page down until the deps were narrowed to the value itself.
const setNameRef = useRef(setName);
setNameRef.current = setName;
useEffect(() => {
setNameRef.current(name);
return () => setNameRef.current(null);
}, [name]);
}