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 { useWorkspace } from '../../components/Workspace';
|
||||||
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
|
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
|
||||||
import { useChat } from '../../hooks/useChat';
|
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 { EmbeddableChat } from './EmbeddableChat';
|
||||||
import { AgentIdentityBar } from './AgentIdentityBar';
|
import { AgentIdentityBar } from './AgentIdentityBar';
|
||||||
import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
|
import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
|
||||||
@@ -9,6 +12,19 @@ import { usePanelSession } from './use-panel-session';
|
|||||||
import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { cwdToPath } from '../../components/Workspace';
|
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 = {
|
type ChatPanelInnerProps = {
|
||||||
scoped: boolean;
|
scoped: boolean;
|
||||||
cwdParam?: { root?: string; path: string };
|
cwdParam?: { root?: string; path: string };
|
||||||
@@ -20,6 +36,8 @@ type ChatPanelInnerProps = {
|
|||||||
agentPanel: UseAgentPanelType;
|
agentPanel: UseAgentPanelType;
|
||||||
/** Records the id the harness mints for a brand-new conversation, so a reload comes back to it. */
|
/** Records the id the harness mints for a brand-new conversation, so a reload comes back to it. */
|
||||||
onSessionId?: (id: string | null) => void;
|
onSessionId?: (id: string | null) => void;
|
||||||
|
/** The transcript to open on, already fetched. Absent for a new conversation. */
|
||||||
|
resume?: ResumeState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatPanelInner = ({
|
const ChatPanelInner = ({
|
||||||
@@ -32,11 +50,30 @@ const ChatPanelInner = ({
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
agentPanel,
|
agentPanel,
|
||||||
onSessionId,
|
onSessionId,
|
||||||
|
resume,
|
||||||
}: ChatPanelInnerProps) => {
|
}: 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,
|
replaceUrl: false,
|
||||||
projectScoped: scoped,
|
projectScoped: scoped,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
|
...(resume
|
||||||
|
? {
|
||||||
|
resumeSessionId: resume.id,
|
||||||
|
initialMessages: resume.messages,
|
||||||
|
paginate: { sessionId: resume.id, total: resume.total, initialOffset: resume.offset },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...chatContext,
|
...chatContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,6 +174,51 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
|||||||
const selectedSessionId =
|
const selectedSessionId =
|
||||||
agentPanel.agent?.sessionKey ?? (neverChose ? sessions[0]?.id : (panelSessionId ?? undefined));
|
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
|
// 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
|
// `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
|
// 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.
|
// would remount onto something else — throwing away whatever had been typed or sent in between.
|
||||||
if (neverChose && !agentPanel.agent && sessionsLoading) return null;
|
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 (
|
return (
|
||||||
<ChatPanelInner
|
<ChatPanelInner
|
||||||
key={key}
|
key={key}
|
||||||
@@ -162,6 +248,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
|||||||
setActiveSession={setActiveSession}
|
setActiveSession={setActiveSession}
|
||||||
onTurnComplete={onTurnComplete}
|
onTurnComplete={onTurnComplete}
|
||||||
sessionKey={selectedSessionId}
|
sessionKey={selectedSessionId}
|
||||||
|
resume={resume?.id === selectedSessionId ? resume : undefined}
|
||||||
onSessionId={agentPanel.agent ? undefined : setPanelSessionId}
|
onSessionId={agentPanel.agent ? undefined : setPanelSessionId}
|
||||||
agentPanel={agentPanel}
|
agentPanel={agentPanel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user