fix(frontend): reconnect stream on same-session resume

This commit is contained in:
User
2026-07-14 20:53:19 +02:00
parent b1d1284cc8
commit 1393c6358e
5 changed files with 90 additions and 47 deletions
@@ -96,6 +96,26 @@ test("Resume paints the re-entry phase from the manifest (optimistic, before the
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
});
test("resuming the active session reconnects its EventSource", async () => {
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 204 })),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const first = FakeEventSource.instances[0];
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(first.closed).toBe(true);
});
test("a failed resume keeps the panel open and does not activate the session", async () => {
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
wrap();
+4 -1
View File
@@ -32,6 +32,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
*/
export function AppShell() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [streamGeneration, setStreamGeneration] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
@@ -100,6 +101,7 @@ export function AppShell() {
}
async function doResume(id: string) {
const s = sessions.find((x) => x.id === id) ?? null;
const reconnectSameSession = activeSessionId === id;
// Optimistic switch: change to the session view IMMEDIATELY so the click feels
// instant (the resume POST spawns a Pi process and can take seconds). The
// working spinner shows straight away; the backend calls run after.
@@ -111,6 +113,7 @@ export function AppShell() {
setActiveSessionId(id);
try {
await resumeSession(id);
if (reconnectSameSession) setStreamGeneration((value) => value + 1);
// Optimistic phase paint: colour the re-entry phase before the first gate.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
@@ -218,7 +221,7 @@ export function AppShell() {
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
useSessionStream(activeSessionId);
useSessionStream(activeSessionId, streamGeneration);
// 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