diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 1aab8a13..7f1299a8 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import type { PiProcessManager } from "../pi/pi-process-manager.js"; -import type { ThtRunner } from "../tht/tht-runner.js"; +import type { SessionRow, ThtRunner } from "../tht/tht-runner.js"; import type { SseHub } from "../sse/sse-hub.js"; import type { Settings } from "../settings/settings-store.js"; import { getPrincipal } from "../auth/auth.js"; @@ -213,7 +213,12 @@ export function sessionRoutes( const settings = await d.getSettings(principal); // Admin RLS is deliberately disabled for a normal 'mine' listing. const scopedPrincipal = scope === "mine" ? { ...principal, isAdmin: false } : principal; - return await runnerFor(scopedPrincipal).sessionList(settings.workspace); + const list: SessionRow[] = await runnerFor(scopedPrincipal).sessionList(settings.workspace); + // Annotate each row with whether a live Pi runtime is currently bound. The client + // opens an `active` session straight into its live view (reconnecting to its pending + // gate), while a cold session keeps its explicit Resume affordance — so a mere click + // never spawns a runtime. + return list.map((row) => ({ ...row, active: d.mgr.get(row.id) !== undefined })); } catch { return storageFailure(reply); } }); app.get("/sessions/:id", async (req, reply) => { diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 96b6c719..565c7b3c 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -109,11 +109,11 @@ test("A, B, and admin requests preserve owner isolation through session route mu const adminHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" }; expect((await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders })).json()) - .toEqual([{ id: "a", author: "alice" }]); + .toEqual([{ id: "a", author: "alice", active: false }]); expect((await app.inject({ method: "GET", url: "/sessions", headers: bobHeaders })).json()) - .toEqual([{ id: "b", author: "bob" }]); + .toEqual([{ id: "b", author: "bob", active: false }]); expect((await app.inject({ method: "GET", url: "/sessions?scope=all", headers: adminHeaders })).json()) - .toEqual([{ id: "a", author: "alice" }, { id: "b", author: "bob" }]); + .toEqual([{ id: "a", author: "alice", active: false }, { id: "b", author: "bob", active: false }]); expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: bobHeaders })).statusCode) .toBe(404); @@ -174,7 +174,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a expect(sessionNewArg.thinking).toBe("high"); expect(sessionNewArg.question).toBe("q"); const list = await app.inject({ method: "GET", url: "/sessions" }); - expect(list.json()).toEqual([{ id: "s1" }]); + expect(list.json()).toEqual([{ id: "s1", active: expect.any(Boolean) }]); unlinkSync(modelKey); }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 872e7543..9901e7d8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -102,6 +102,8 @@ export interface SessionSummary { name: string | null; group: string | null; archived: boolean; + /** True while a live Pi runtime is bound: opening it reconnects to its pending gate. */ + active?: boolean; } export type SessionScope = "mine" | "all"; diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index d6130b12..486a7202 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -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()); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index cea56bae..5d69b82c 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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); diff --git a/frontend/src/shell/useActivityPanelResize.test.ts b/frontend/src/shell/useActivityPanelResize.test.ts index 5b8e1f2a..b31036e9 100644 --- a/frontend/src/shell/useActivityPanelResize.test.ts +++ b/frontend/src/shell/useActivityPanelResize.test.ts @@ -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", () => { diff --git a/frontend/src/shell/useActivityPanelResize.ts b/frontend/src/shell/useActivityPanelResize.ts index d7d0e177..862d69ee 100644 --- a/frontend/src/shell/useActivityPanelResize.ts +++ b/frontend/src/shell/useActivityPanelResize.ts @@ -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), ), }; } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 2e77f7ed..ed2ee3d1 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,6 +1,26 @@ import "@testing-library/jest-dom/vitest"; import { server } from "./msw"; +// Node 25 ships an experimental global `localStorage` that shadows jsdom's Storage and +// lacks spec methods like clear(), so tests calling localStorage.clear()/setItem() throw. +// Swap in a spec-compliant in-memory Storage. Feature-detected: inert on runtimes whose +// localStorage already implements clear() (e.g. CI on an LTS Node with working jsdom). +if (typeof localStorage === "undefined" || typeof localStorage.clear !== "function") { + const store = new Map(); + const storage: Storage = { + get length() { return store.size; }, + clear() { store.clear(); }, + getItem(key: string) { return store.has(key) ? store.get(key)! : null; }, + key(index: number) { return Array.from(store.keys())[index] ?? null; }, + removeItem(key: string) { store.delete(key); }, + setItem(key: string, value: string) { store.set(String(key), String(value)); }, + }; + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true, writable: true }); + if (typeof window !== "undefined") { + Object.defineProperty(window, "localStorage", { value: storage, configurable: true, writable: true }); + } +} + // jsdom lacks matchMedia; sonner's (mounted in AppShell) needs it. if (!window.matchMedia) { window.matchMedia = (query: string) =>