resuming a conversation needs its transcript, not just its id

Switching to an existing chat showed the right title and an empty pane. The
session was correct; none of what was said in it was there.

Passing the id as `initialSessionId` seeds the socket's resume handshake, so the
AGENT continues the right conversation — but nothing ever fetches the messages, so
the UI has none to draw. The two halves disagreed silently: send a turn and it
would have carried on correctly above a history you could not see.

The /chat screen has always done it properly, and this now does the same:
`resumeSessionId` + `initialMessages` + `paginate`, from
`GET /chat/sessions/:id?limit=20`. Only the tail is loaded — transcripts get very
long — and `total`/`offset` tell the chat where that window sits so scrolling up
pages the rest in.

── Fetched BEFORE the chat mounts, not after ──

`useChat` seeds its state from the options given on its first render, so loading
afterwards is too late: the messages would arrive at a hook that has already
decided it has none. The panel therefore holds off rendering while a transcript is
in flight, which also avoids showing an empty conversation for a moment and then
replacing it — that reads as history being lost and then found.

A stale id (transcript deleted or pruned) opens a NEW conversation rather than an
empty pane, because an empty pane is indistinguishable from a conversation whose
history vanished.

This is the same class as the cwd bug an hour ago: the id was right, the wiring
was right, and the thing that makes it visible was never asked for.

tsgo clean, frontend builds, 846 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 23:40:24 +00:00
co-authored by Claude Opus 5
parent 5998492dcd
commit f534b818b9
@@ -1,7 +1,10 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
import { useChat } from '../../hooks/useChat';
import { useClient } from 'hooks/useClient';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import type { ChatMessage } from './types';
import { EmbeddableChat } from './EmbeddableChat';
import { AgentIdentityBar } from './AgentIdentityBar';
import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
@@ -9,6 +12,19 @@ import { usePanelSession } from './use-panel-session';
import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
import { cwdToPath } from '../../components/Workspace';
/** A fetched transcript, ready to open on. */
type ResumeState = {
id: string;
model: string | null;
messages: ChatMessage[];
total: number;
offset: number;
};
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
// on scroll. Matches the /chat screen.
const CHAT_TAIL = 20;
type ChatPanelInnerProps = {
scoped: boolean;
cwdParam?: { root?: string; path: string };
@@ -20,6 +36,8 @@ type ChatPanelInnerProps = {
agentPanel: UseAgentPanelType;
/** Records the id the harness mints for a brand-new conversation, so a reload comes back to it. */
onSessionId?: (id: string | null) => void;
/** The transcript to open on, already fetched. Absent for a new conversation. */
resume?: ResumeState;
};
const ChatPanelInner = ({
@@ -32,11 +50,30 @@ const ChatPanelInner = ({
sessionKey,
agentPanel,
onSessionId,
resume,
}: ChatPanelInnerProps) => {
const chat = useChat(sessionKey, undefined, {
/**
* Resuming needs the TRANSCRIPT, not just the id.
*
* Passing the id as `initialSessionId` seeds the socket's resume handshake, so the agent continues the
* right conversation — but the pane stays empty, because nothing ever fetched what was said. That is
* exactly what "I switched to my old chat and it showed nothing" was: the right session, with none of
* its history in it.
*
* `resumeSessionId` + `initialMessages` + `paginate` is how the /chat screen does it, and the same
* three are what a panel needs.
*/
const chat = useChat(resume ? undefined : sessionKey, resume?.model ?? undefined, {
replaceUrl: false,
projectScoped: scoped,
onTurnComplete,
...(resume
? {
resumeSessionId: resume.id,
initialMessages: resume.messages,
paginate: { sessionId: resume.id, total: resume.total, initialOffset: resume.offset },
}
: {}),
...chatContext,
});
@@ -137,6 +174,51 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
const selectedSessionId =
agentPanel.agent?.sessionKey ?? (neverChose ? sessions[0]?.id : (panelSessionId ?? undefined));
/**
* Fetch the transcript BEFORE the chat mounts.
*
* `useChat` seeds its state from the options it is given on its first render, so loading afterwards is
* too late — the messages would arrive at a hook that has already decided it has none. The /chat screen
* has the same constraint and solves it the same way: resolve the session, then render the pane.
*
* Only the tail window, because transcripts get extremely long; `total` and `offset` tell the chat
* where that window sits so scrolling up can page in the rest.
*/
const client = useClient();
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
const [resuming, setResuming] = useState(false);
useEffect(() => {
if (!selectedSessionId) {
setResume(undefined);
return;
}
let cancelled = false;
setResuming(true);
void (async () => {
try {
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${selectedSessionId}?limit=${CHAT_TAIL}`);
if (cancelled) return;
setResume({
id: selectedSessionId,
model: detail.model,
messages: detail.messages as unknown as ChatMessage[],
total: detail.total,
offset: detail.offset,
});
} catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
// than an empty pane, which looks like a conversation that lost its history.
if (!cancelled) setResume(undefined);
} finally {
if (!cancelled) setResuming(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedSessionId]);
// The key has to be decided before `useChat` mounts — the hook seeds its state from the first
// `initialSessionId` it sees, and the socket's resume handshake sends whatever is in that state on
// connect. Remounting on the key is the honest way to say "this is now a different conversation", and
@@ -152,6 +234,10 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
// would remount onto something else — throwing away whatever had been typed or sent in between.
if (neverChose && !agentPanel.agent && sessionsLoading) return null;
// And not while a transcript is on its way: mounting now shows an empty conversation for a moment and
// then replaces it, which reads as the history having been lost and then found.
if (selectedSessionId && resuming) return null;
return (
<ChatPanelInner
key={key}
@@ -162,6 +248,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
setActiveSession={setActiveSession}
onTurnComplete={onTurnComplete}
sessionKey={selectedSessionId}
resume={resume?.id === selectedSessionId ? resume : undefined}
onSessionId={agentPanel.agent ? undefined : setPanelSessionId}
agentPanel={agentPanel}
/>