From f534b818b9617fb70fe7144d636330342f7c3549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 23:40:24 +0000 Subject: [PATCH] resuming a conversation needs its transcript, not just its id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/Chat/ChatPanelWrapper.tsx | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index fa542d1b..d11f347e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -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(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(`/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 (