fix(frontend): reconnect stream on same-session resume
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,3 +46,17 @@ test("closes the stream on unmount", () => {
|
||||
unmount();
|
||||
expect(es.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("reconnects the same session when the generation changes", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ generation }) => useSessionStream("s1", generation),
|
||||
{ initialProps: { generation: 0 } },
|
||||
);
|
||||
const first = FakeEventSource.instances[0];
|
||||
|
||||
rerender({ generation: 1 });
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
expect(FakeEventSource.instances).toHaveLength(2);
|
||||
expect(FakeEventSource.instances[1].url).toBe(first.url);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { joinBackendPath } from "../api/runtime-config";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import type { StreamEvent } from "../api/types";
|
||||
|
||||
export function useSessionStream(sessionId: string | null) {
|
||||
export function useSessionStream(sessionId: string | null, generation = 0) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const applyEvent = useSessionStore((s) => s.applyEvent);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useSessionStream(sessionId: string | null) {
|
||||
es.close();
|
||||
setConnected(false);
|
||||
};
|
||||
}, [sessionId, applyEvent]);
|
||||
}, [sessionId, generation, applyEvent]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user