From db7e3d3dce817a8b6e7d09430651d0704a821649 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 17 Sep 2026 15:23:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20le=20changement=20d'onglet=20n?= =?UTF-8?q?e=20d=C3=A9clenche=20plus=20de=20reprise=20de=20la=20conversati?= =?UTF-8?q?on=20active=20=E2=80=94=20au=20remontage=20d'une=20cellule=20av?= =?UTF-8?q?ec=20conversationId,=20r=C3=A9attache=20d'abord=20la=20session?= =?UTF-8?q?=20structur=C3=A9e=20vivante=20(sessionId=20du=20cache=20de=20c?= =?UTF-8?q?onversation,=20puis=20registre=20listLiveAgents/attachLiveAgent?= =?UTF-8?q?=20avec=20rebinding=20de=20node)=20via=20reattachStructuredSess?= =?UTF-8?q?ion=20preserveViewOnEmptyScrollback=20avant=20tout=20chargement?= =?UTF-8?q?=20d'historique=20durable=20;=20le=20transcript=20persist=C3=A9?= =?UTF-8?q?=20(conversation.readPage)=20n'est=20consult=C3=A9=20qu'en=20l'?= =?UTF-8?q?absence=20de=20runtime=20live=20et=20la=20banni=C3=A8re=20?= =?UTF-8?q?=C2=AB=20Conversation=20reprise=20=C2=BB=20reste=20r=C3=A9serv?= =?UTF-8?q?=C3=A9e=20=C3=A0=20la=20reprise=20explicite=20depuis=20le=20men?= =?UTF-8?q?u=20Conversations=20=E2=80=94=20=C3=A9viction=20du=20cache=20de?= =?UTF-8?q?=20session=20morte=20(chatSessionViewCache.delete)=20r=C3=A9con?= =?UTF-8?q?cili=C3=A9e=20avec=20le=20cache=20de=20conversation=20pour=20ne?= =?UTF-8?q?=20pas=20rejouer=20un=20transcript=20obsol=C3=A8te=20;=20tests:?= =?UTF-8?q?=20r=C3=A9attach=20live=20au=20remontage=20sans=20readPage=20ni?= =?UTF-8?q?=20banni=C3=A8re=20(#370),=20retour=20depuis=20une=20vue=20non?= =?UTF-8?q?=20conversationnelle=20=3D=20replay=20live=20complet=20(second?= =?UTF-8?q?=20reattach,=20pas=20de=20readPage,=20pas=20de=20banni=C3=A8re)?= =?UTF-8?q?=20=E2=80=94=20#370=20(QA=20verte:=20npm=20ci,=20npm=20run=20ty?= =?UTF-8?q?pecheck,=20npx=20vitest=20run=20src/features/agents/CustomAgent?= =?UTF-8?q?ChatView.test.tsx=20src/features/layout/LayoutGrid.chat.test.ts?= =?UTF-8?q?x,=20git=20diff=20--check=20;=20fichiers=20.ideai/*=20et=20scri?= =?UTF-8?q?pts/=20hors=20p=C3=A9rim=C3=A8tre=20;=20aucun=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/CustomAgentChatView.test.tsx | 78 ++++++++++++++++++- .../features/agents/CustomAgentChatView.tsx | 68 +++++++++++++++- 2 files changed, 143 insertions(+), 3 deletions(-) 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, ]);