diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx
index 207603a6..74f96608 100644
--- a/frontend/src/features/agents/CustomAgentChatView.test.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx
@@ -433,6 +433,73 @@ describe("CustomAgentChatView", () => {
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
+ it("reattaches a live conversation on remount without reading durable history (#370)", async () => {
+ let receive: ((chunk: ReplyChunk) => void) | undefined;
+ const agent = {
+ launchAgentChat: vi.fn(),
+ listLiveAgents: vi.fn(async () => [{
+ agentId: "agent-1",
+ nodeId: "node-1",
+ sessionId: "live-session",
+ kind: "structured" as const,
+ runtimeState: "running" as const,
+ }]),
+ reattachAgentChat: vi.fn(async (
+ sessionId: string,
+ onChunk: (chunk: ReplyChunk) => void,
+ ) => {
+ receive = onChunk;
+ return {
+ sessionId,
+ conversationId: "active-conversation",
+ scrollback: [
+ { kind: "userPrompt" as const, text: "Question active" },
+ { kind: "textDelta" as const, text: "Réponse en cours" },
+ ],
+ };
+ }),
+ sendAgentChat: vi.fn(async () => {}),
+ cancelAgentChat: vi.fn(async () => {}),
+ closeAgentChat: vi.fn(async () => {}),
+ };
+ const conversation = {
+ readPage: vi.fn(async () => ({ turns: [], hasMore: false })),
+ };
+ const onSessionId = vi.fn();
+
+ render(
+ null) },
+ } as unknown as Gateways}>
+
+ ,
+ );
+
+ expect(await screen.findByText("Question active")).toBeTruthy();
+ expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0);
+ expect(conversation.readPage).not.toHaveBeenCalled();
+ expect(screen.queryByText("Conversation reprise")).toBeNull();
+ expect(onSessionId).toHaveBeenCalledWith("live-session");
+
+ act(() => receive?.({ kind: "final", content: "Réponse terminée" }));
+ expect(await screen.findByText("Réponse terminée")).toBeTruthy();
+ expect(screen.getByText("Question active")).toBeTruthy();
+ expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0);
+ });
+
it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => {
const agent = {
launchAgentChat: vi.fn(),
@@ -1602,7 +1669,7 @@ describe("CustomAgentChatView", () => {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
.fn()
- .mockImplementationOnce(async (sessionId: string) => ({
+ .mockImplementation(async (sessionId: string) => ({
sessionId,
scrollback,
})),
@@ -1639,6 +1706,11 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("Initial reply")).toBeTruthy();
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
+ scrollback = [
+ ...scrollback,
+ { kind: "userPrompt", text: "Question during graph view" },
+ { kind: "final", content: "Answer restored from replay" },
+ ];
render(
{
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
expect(screen.getByText("Question during graph view")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
- expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
+ expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2);
+ expect(conversation.readPage).not.toHaveBeenCalled();
+ expect(screen.queryByText("Conversation reprise")).toBeNull();
});
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx
index a6944dc7..7018bf39 100644
--- a/frontend/src/features/agents/CustomAgentChatView.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.tsx
@@ -1885,6 +1885,7 @@ export function CustomAgentChatView({
applyScrollback?: boolean;
stableIdentity?: boolean;
preserveRestartTurns?: boolean;
+ preserveViewOnEmptyScrollback?: boolean;
} = {},
) => {
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
@@ -1921,7 +1922,10 @@ export function CustomAgentChatView({
if (reattached.conversationId) {
publishConversationId(reattached.conversationId);
}
- if (options.applyScrollback) {
+ if (
+ options.applyScrollback &&
+ (reattached.scrollback.length > 0 || !options.preserveViewOnEmptyScrollback)
+ ) {
scrollbackChunkCountRef.current = reattached.scrollback.length;
let replayed = reattached.scrollback.reduce(
(state, chunk) => foldChunk(state, chunk),
@@ -2214,6 +2218,21 @@ export function CustomAgentChatView({
if (!isNotFound(stableError)) throw stableError;
}
if (!cancelled) {
+ chatSessionViewCache.delete(externalSessionId);
+ const cacheKey = conversationCacheKey(
+ projectId,
+ agentId,
+ activeConversationIdRef.current,
+ );
+ const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
+ if (cacheKey && cached?.sessionId === externalSessionId) {
+ chatConversationViewCache.set(cacheKey, {
+ turns: cached.turns,
+ progressIndex: cached.progressIndex,
+ progressScope: cached.progressScope,
+ chunkCount: cached.chunkCount,
+ });
+ }
setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
@@ -2256,6 +2275,51 @@ export function CustomAgentChatView({
return;
}
+ // A cell can remount while its structured runtime remains live (for
+ // example after switching tabs). The layout session can temporarily
+ // be absent, but the conversation cache and live registry still carry
+ // the runtime identity. Reattach that runtime before considering the
+ // durable transcript: a conversation id alone is not a request to
+ // replace an active view with saved history (#370).
+ const cachedSessionId = cached?.sessionId;
+ if (cachedSessionId) {
+ try {
+ const attachedSessionId = await reattachStructuredSession(cachedSessionId, {
+ applyScrollback: true,
+ stableIdentity: true,
+ preserveViewOnEmptyScrollback: true,
+ });
+ if (cancelled) return;
+ publishSessionId(attachedSessionId);
+ setSavedConversationLoadState("idle");
+ setResumedConversation(false);
+ return;
+ } catch (e) {
+ if (!isNotFound(e)) throw e;
+ }
+ }
+
+ const live = agent.listLiveAgents
+ ? (await agent.listLiveAgents(projectId)).find(
+ (candidate) => candidate.agentId === agentId && candidate.kind === "structured",
+ )
+ : undefined;
+ if (live) {
+ const rebound =
+ nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
+ ? await agent.attachLiveAgent(projectId, agentId, nodeId)
+ : live;
+ const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
+ applyScrollback: true,
+ preserveViewOnEmptyScrollback: true,
+ });
+ if (cancelled) return;
+ publishSessionId(attachedSessionId);
+ setSavedConversationLoadState("idle");
+ setResumedConversation(false);
+ return;
+ }
+
setSavedConversationLoadState("loading");
setLiveSessionAttached(false);
try {
@@ -2311,8 +2375,10 @@ export function CustomAgentChatView({
publishSessionId,
applyChatViewState,
conversation,
+ agent,
projectId,
agentId,
+ nodeId,
freshOnOpen,
openRetryNonce,
]);