make a pane actually open the conversation you clicked

Reported: three panes, MacBook selected, click a chat and the body says "No sessions yet".

Two causes, both from panes bypassing the screen-level machinery on purpose.

The transcript was never loaded. The screen resolver fetches it and writes to the shared
channel, which a pane deliberately does not read, so the pane got {id, title, cwd} and
nothing else. It resolves its own now, from ITS server — two machines can hold the same uuid,
so asking the wrong one is not merely empty, it is wrong — and shows a spinner while it does
rather than an empty conversation.

And the row navigated. That put /chat/<id> in the address bar, which reset the list cwd to
the default — empty on that machine — which is the "No sessions yet" he actually saw. In a
pane the directory is the pane, not the route: three panes cannot share one URL. Outside a
pane everything still comes from the route exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:06:09 +01:00
co-authored by Claude Opus 5
parent 80d66538c2
commit 4b3668f03c
3 changed files with 89 additions and 7 deletions
@@ -1,4 +1,6 @@
import { ArrowLeft } from 'lucide-react';
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';
@@ -27,6 +29,60 @@ type ChatPaneProps = {
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">
@@ -50,7 +106,17 @@ export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPanePr
</div>
)}
<div className="min-h-0 flex-1">{open ? <ChatDetailPanel /> : <SessionList />}</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>
);
@@ -21,6 +21,16 @@ type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null
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();
@@ -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 } from './PaneSelection';
import { usePaneSelection, useIsInPane } from './PaneSelection';
import { errorText } from 'helpers/error-text';
import { useClaudeSessions } from 'state/useClaudeSessions';
import { ServerChips } from './ServerChips';
@@ -24,7 +24,11 @@ export const SessionList = () => {
// 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.
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null;
// 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);
// 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.
@@ -88,10 +92,11 @@ 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);
navigate(chatListPath(null), { replace: true });
if (!inPane) navigate(chatListPath(null), { replace: true });
}}
/>
<PwdSelector
@@ -99,7 +104,8 @@ export const SessionList = () => {
value={activeCwd}
onChange={(cwd) => {
setSelected(null); // sessions belong to a cwd — clear the open one when switching
navigate(chatListPath(cwd), { replace: true });
if (inPane) setPaneCwd(cwd);
else navigate(chatListPath(cwd), { replace: true });
}}
/>
<div className="ml-auto flex items-center gap-1.5">
@@ -206,7 +212,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={linkTo(session.id)}
to={inPane ? undefined : 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