feat(frontend): open a live session straight to its pending gate

Opening an in-progress session that has a live Pi runtime now reconnects to
its pending gate instead of the empty landing screen that read as "stopped".
Cold/completed sessions keep the read-only documents panel with its explicit
Resume, so a mere click never spawns a runtime. Backend GET /sessions now
reports a per-session `active` flag (live runtime bound) to drive this.

Also:
- "New session" now closes any open session detail panel (left box).
- The model-activity separator can be dragged to a full 50/50 split
  (was capped at 576px); central-min still guards narrow viewports.

Test fixes uncovered along the way:
- Node 25 ships an experimental global localStorage that shadows jsdom's and
  lacks clear(), failing every jsdom test at setup; install a spec-compliant
  in-memory Storage (feature-detected, inert on CI/LTS).
- Fix 4 pre-existing session-mgmt tests that used an ambiguous getByText for a
  session shown in both nav and header; target the nav item by test id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:56:12 +02:00
co-authored by Claude Opus 4.8
parent 6274bf2da0
commit f979ada5e7
8 changed files with 92 additions and 17 deletions
@@ -181,6 +181,37 @@ test("opening the panel shows the session documents", async () => {
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
});
test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => {
let resumed: string | null = null;
server.use(
http.get("http://localhost:8787/sessions", () =>
HttpResponse.json([{ ...LIST[0], active: true }])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
resumed = params.id as string;
return resumeResult(resumed, true); // warm runtime → alreadyActive
}),
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 waitFor(() => expect(resumed).toBe("s1"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
// Live view, not the read-only documents panel.
expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument();
});
test("New session closes an open session detail panel", async () => {
server.use(http.post("http://localhost:8787/runtime/prewarm", () =>
HttpResponse.json({ status: "warming" }, { status: 202 })));
wrap();
await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens
await screen.findByText("Domanda originale");
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
test("Archive accordion expands to reveal archived sessions", async () => {
wrap();
// archived hidden until the Archive accordion is expanded
@@ -246,7 +277,7 @@ test("an already-active same-session Resume preserves its EventSource and store"
act(() => first.emitNamed("info", { type: "info", text: "Live state" }, "4"));
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(screen.getByTestId("session-item-s1"));
await screen.findByText("Domanda originale");
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
@@ -278,7 +309,7 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
const oldSource = FakeEventSource.instances[0];
act(() => oldSource.emitNamed("info", { type: "info", text: "Old generation" }, "900"));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(screen.getByTestId("session-item-s1"));
const resumeButton = await screen.findByRole("button", { name: /resume/i });
await userEvent.click(resumeButton);
await coldStarted.promise;
@@ -343,7 +374,7 @@ test("a committed Resume releases same-id single-flight before its manifest sett
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(screen.getByTestId("session-item-s1"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(s1ResumeCalls).toBe(2));
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
@@ -380,7 +411,7 @@ test("cold same-session Resume keeps the old stream until success then receives
return resumeResult("s1");
}));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(screen.getByTestId("session-item-s1"));
await screen.findByText("Domanda originale");
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted;
@@ -446,7 +477,7 @@ test("a failed same-session Resume preserves its source, activity, and document
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 409 })));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(screen.getByTestId("session-item-s1"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
+14 -1
View File
@@ -122,7 +122,17 @@ export function AppShell() {
function openPanel(id: string) {
const s = sessions.find((x) => x.id === id);
if (s) { setPanelSession(s); setShowActivity(false); }
if (!s) return;
// A session with a live Pi runtime opens straight into its live view: doResume
// reconnects to the already-active runtime and replays its pending gate, so an
// in-progress session never shows an empty screen that reads as "stopped". Cold or
// completed sessions keep the read-only documents panel (with its explicit Resume),
// so a mere click never spawns a runtime.
if (s.active && s.status === "open" && !s.archived && !isForeignSession(s)) {
void doResume(id);
return;
}
setPanelSession(s); setShowActivity(false);
}
function toggleActivity() {
setShowActivity((v) => {
@@ -351,6 +361,9 @@ export function AppShell() {
function startNewSession() {
invalidateResumeIntent();
resetSession();
// Starting a new question closes any open session detail panel: the reader is
// moving away from that session, so its left-hand box must not linger.
setPanelSession(null);
setAwaitingQuestion(true);
setCreatingSession(false);
selectActiveSession(null);
@@ -10,10 +10,11 @@ import {
} from "./useActivityPanelResize";
describe("activity panel resize contract", () => {
test("computes desktop bounds while preserving 512px centrally", () => {
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 576 });
expect(getActivityPanelBounds(1024)).toEqual({ min: 288, max: 512 });
expect(getActivityPanelBounds(800)).toEqual({ min: 288, max: 288 });
test("lets the panel reach a 50/50 split while preserving 512px centrally when narrow", () => {
expect(getActivityPanelBounds(2000)).toEqual({ min: 288, max: 1000 }); // exactly 50%
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 600 }); // 50%, central 600 ≥ 512
expect(getActivityPanelBounds(1024)).toEqual({ min: 288, max: 512 }); // 50% == central-min
expect(getActivityPanelBounds(800)).toEqual({ min: 288, max: 288 }); // central-min binds before 50%
});
test("clamps finite values and falls back for invalid values", () => {
+4 -1
View File
@@ -3,6 +3,7 @@ import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
/** Pre-measurement fallback ceiling only; the measured bound is 50% of the container (see below). */
export const ACTIVITY_PANEL_MAX_WIDTH = 576;
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
@@ -10,11 +11,13 @@ export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
export type ActivityPanelBounds = { min: number; max: number };
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
// The separator may be dragged out to a full 50/50 split. On containers too narrow
// for that (below ~1024px) the central-min guard binds first, keeping the chat usable.
return {
min: ACTIVITY_PANEL_MIN_WIDTH,
max: Math.max(
ACTIVITY_PANEL_MIN_WIDTH,
Math.min(ACTIVITY_PANEL_MAX_WIDTH, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
Math.min(containerWidth * 0.5, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
),
};
}