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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user