diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 90048af8..0a4f7dd3 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -1213,6 +1213,38 @@ test("closing and reopening Model activity preserves the complete activity log", expect(useSessionStore.getState().activityLog).toEqual(beforeClose); }); +test.each(["stop", "session_exit"])("%s restores the empty landing with model activity closed", async (exit) => { + const close = vi.fn(() => new HttpResponse(null, { status: 204 })); + server.use( + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), + http.post("/api/sessions/:id/close", close), + ); + wrap(adminUser); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: "Resume" })); + await userEvent.click(screen.getByRole("button", { name: "Show model activity" })); + act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Previous question" })); + expect(screen.getByLabelText("Model activity timeline")).toHaveTextContent("Previous question"); + const source = FakeEventSource.instances.at(-1)!; + + if (exit === "stop") { + await userEvent.click(screen.getByRole("button", { name: /stop and save session/i })); + await userEvent.click(screen.getAllByRole("button", { name: "Stop & save" })[0]); + } else { + act(() => source.emit({ type: "system_event", event: "session_exit" })); + } + + expect(await screen.findByText(/type your question/i)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Model activity" })).not.toBeInTheDocument(); + expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed"); + expect(screen.queryByRole("complementary", { name: "Session summary" })).not.toBeInTheDocument(); + expect(screen.getByRole("complementary", { name: "Session navigation" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue(""); + expect(useSessionStore.getState().activityLog).toEqual([]); + expect(source.closed).toBe(true); + expect(close).toHaveBeenCalledTimes(exit === "stop" ? 1 : 0); +}); + test("a new question after finalization restores the landing and administration navigation", async () => { useSessionStore.getState().resetSession(); let finalized = false; diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 164991ca..2b3b215b 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -694,6 +694,20 @@ export function AppShell({ canLogout }: AppShellProps) { authGeneration, ); + function resetSessionView() { + invalidateResumeIntent(); + newSessionOperationRef.current = null; + resetSession(); + selectActiveSession(null); + // Panel visibility belongs to the shell, outside the session store. + // Clear it too so leaving a session restores the initial landing layout. + setPanelSession(null); + setShowActivity(false); + setCreatingSession(false); + setAwaitingQuestion(false); + setStopConfirm(false); + } + // A backend "session_exit" system event (e.g. the replay server emitting it // when the reviewer picks "Esci") asks us to leave the live session view and // return to the landing state. We deliberately do NOT also POST /close here — @@ -705,10 +719,7 @@ export function AppShell({ canLogout }: AppShellProps) { if (ev === "session_exit") { // Never let a streamed event terminate the managed Pi child. Only the // explicit “Stop & save” action is allowed to call /close. - invalidateResumeIntent(); - resetSession(); - selectActiveSession(null); - setAwaitingQuestion(false); + resetSessionView(); } // The final workflow turn ends with the session already finalized on disk: // refetch now instead of waiting for the 10s poll, so the completed state @@ -725,15 +736,8 @@ export function AppShell({ canLogout }: AppShellProps) { } if (!canLeaveDatabaseManagement()) return; navigate({ surface: "core" }, true); - invalidateResumeIntent(); - newSessionOperationRef.current = null; - resetSession(); - // Restore the landing layout, including the right-hand administration rail. - setPanelSession(null); - setShowActivity(false); + resetSessionView(); setAwaitingQuestion(true); - setCreatingSession(false); - selectActiveSession(null); // Best effort only: session creation keeps the authoritative readiness gate. // Composer focus is deliberately independent of this network request. void prewarmRuntime().catch(() => undefined); @@ -781,9 +785,7 @@ export function AppShell({ canLogout }: AppShellProps) { } finally { if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) || activeSessionIdRef.current !== id) return; - resetSession(); - selectActiveSession(null); - setAwaitingQuestion(false); + resetSessionView(); } }