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