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 { connectionLabel } from 'hooks/connections';
|
||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||||
@@ -27,6 +29,60 @@ type ChatPaneProps = {
|
|||||||
export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => {
|
export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => {
|
||||||
const open = !!target;
|
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 (
|
return (
|
||||||
<PaneSelectionProvider value={target} onChange={onTargetChange}>
|
<PaneSelectionProvider value={target} onChange={onTargetChange}>
|
||||||
<div className="flex h-full min-w-0 flex-col">
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
@@ -50,7 +106,17 @@ export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPanePr
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
</PaneSelectionProvider>
|
</PaneSelectionProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null
|
|||||||
|
|
||||||
const PaneSelectionContext = createContext<PaneSelectionValue | null>(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 {
|
export function usePaneSelection(): PaneSelectionValue {
|
||||||
const scoped = useContext(PaneSelectionContext);
|
const scoped = useContext(PaneSelectionContext);
|
||||||
const channel = useSelectedChatSession();
|
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 { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data';
|
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 { errorText } from 'helpers/error-text';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { ServerChips } from './ServerChips';
|
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
|
// 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 =
|
// 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;
|
// 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
|
// 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.
|
// 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}
|
value={serverId}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setServerId(next);
|
setServerId(next);
|
||||||
|
setPaneCwd(null);
|
||||||
// A path and a conversation from the machine you left name nothing on the one you arrived
|
// 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`).
|
// at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`).
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
navigate(chatListPath(null), { replace: true });
|
if (!inPane) navigate(chatListPath(null), { replace: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PwdSelector
|
<PwdSelector
|
||||||
@@ -99,7 +104,8 @@ export const SessionList = () => {
|
|||||||
value={activeCwd}
|
value={activeCwd}
|
||||||
onChange={(cwd) => {
|
onChange={(cwd) => {
|
||||||
setSelected(null); // sessions belong to a cwd — clear the open one when switching
|
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">
|
<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
|
{/* 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. */}
|
not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
|
||||||
<DataRow
|
<DataRow
|
||||||
to={linkTo(session.id)}
|
to={inPane ? undefined : linkTo(session.id)}
|
||||||
// Stamp the machine onto the selection BEFORE the route changes. The resolver that
|
// 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
|
// 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
|
// looked up on this origin, where that id names nothing (or, worse, names something
|
||||||
|
|||||||
Reference in New Issue
Block a user