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:
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { PiProcessManager } from "../pi/pi-process-manager.js";
|
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 { SseHub } from "../sse/sse-hub.js";
|
||||||
import type { Settings } from "../settings/settings-store.js";
|
import type { Settings } from "../settings/settings-store.js";
|
||||||
import { getPrincipal } from "../auth/auth.js";
|
import { getPrincipal } from "../auth/auth.js";
|
||||||
@@ -213,7 +213,12 @@ export function sessionRoutes(
|
|||||||
const settings = await d.getSettings(principal);
|
const settings = await d.getSettings(principal);
|
||||||
// Admin RLS is deliberately disabled for a normal 'mine' listing.
|
// Admin RLS is deliberately disabled for a normal 'mine' listing.
|
||||||
const scopedPrincipal = scope === "mine" ? { ...principal, isAdmin: false } : principal;
|
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); }
|
} catch { return storageFailure(reply); }
|
||||||
});
|
});
|
||||||
app.get("/sessions/:id", async (req, reply) => {
|
app.get("/sessions/:id", async (req, reply) => {
|
||||||
|
|||||||
@@ -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" };
|
const adminHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
||||||
|
|
||||||
expect((await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders })).json())
|
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())
|
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())
|
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)
|
expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: bobHeaders })).statusCode)
|
||||||
.toBe(404);
|
.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.thinking).toBe("high");
|
||||||
expect(sessionNewArg.question).toBe("q");
|
expect(sessionNewArg.question).toBe("q");
|
||||||
const list = await app.inject({ method: "GET", url: "/sessions" });
|
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);
|
unlinkSync(modelKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface SessionSummary {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
group: string | null;
|
group: string | null;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** True while a live Pi runtime is bound: opening it reconnects to its pending gate. */
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionScope = "mine" | "all";
|
export type SessionScope = "mine" | "all";
|
||||||
|
|||||||
@@ -181,6 +181,37 @@ test("opening the panel shows the session documents", async () => {
|
|||||||
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
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 () => {
|
test("Archive accordion expands to reveal archived sessions", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
// archived hidden until the Archive accordion is expanded
|
// 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"));
|
act(() => first.emitNamed("info", { type: "info", text: "Live state" }, "4"));
|
||||||
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
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 screen.findByText("Domanda originale");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
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];
|
const oldSource = FakeEventSource.instances[0];
|
||||||
act(() => oldSource.emitNamed("info", { type: "info", text: "Old generation" }, "900"));
|
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 });
|
const resumeButton = await screen.findByRole("button", { name: /resume/i });
|
||||||
await userEvent.click(resumeButton);
|
await userEvent.click(resumeButton);
|
||||||
await coldStarted.promise;
|
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 userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
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 userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(s1ResumeCalls).toBe(2));
|
await waitFor(() => expect(s1ResumeCalls).toBe(2));
|
||||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
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");
|
return resumeResult("s1");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Attiva uno"));
|
await userEvent.click(screen.getByTestId("session-item-s1"));
|
||||||
await screen.findByText("Domanda originale");
|
await screen.findByText("Domanda originale");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await resumeStarted;
|
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", () =>
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||||
new HttpResponse(null, { status: 409 })));
|
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 userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,17 @@ export function AppShell() {
|
|||||||
|
|
||||||
function openPanel(id: string) {
|
function openPanel(id: string) {
|
||||||
const s = sessions.find((x) => x.id === id);
|
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() {
|
function toggleActivity() {
|
||||||
setShowActivity((v) => {
|
setShowActivity((v) => {
|
||||||
@@ -351,6 +361,9 @@ export function AppShell() {
|
|||||||
function startNewSession() {
|
function startNewSession() {
|
||||||
invalidateResumeIntent();
|
invalidateResumeIntent();
|
||||||
resetSession();
|
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);
|
setAwaitingQuestion(true);
|
||||||
setCreatingSession(false);
|
setCreatingSession(false);
|
||||||
selectActiveSession(null);
|
selectActiveSession(null);
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
} from "./useActivityPanelResize";
|
} from "./useActivityPanelResize";
|
||||||
|
|
||||||
describe("activity panel resize contract", () => {
|
describe("activity panel resize contract", () => {
|
||||||
test("computes desktop bounds while preserving 512px centrally", () => {
|
test("lets the panel reach a 50/50 split while preserving 512px centrally when narrow", () => {
|
||||||
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 576 });
|
expect(getActivityPanelBounds(2000)).toEqual({ min: 288, max: 1000 }); // exactly 50%
|
||||||
expect(getActivityPanelBounds(1024)).toEqual({ min: 288, max: 512 });
|
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 600 }); // 50%, central 600 ≥ 512
|
||||||
expect(getActivityPanelBounds(800)).toEqual({ min: 288, max: 288 });
|
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", () => {
|
test("clamps finite values and falls back for invalid values", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react
|
|||||||
|
|
||||||
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
|
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
|
||||||
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
|
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_PANEL_MAX_WIDTH = 576;
|
||||||
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
|
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
|
||||||
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
|
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 type ActivityPanelBounds = { min: number; max: number };
|
||||||
|
|
||||||
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
|
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 {
|
return {
|
||||||
min: ACTIVITY_PANEL_MIN_WIDTH,
|
min: ACTIVITY_PANEL_MIN_WIDTH,
|
||||||
max: Math.max(
|
max: Math.max(
|
||||||
ACTIVITY_PANEL_MIN_WIDTH,
|
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),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { server } from "./msw";
|
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<string, string>();
|
||||||
|
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 <Toaster> (mounted in AppShell) needs it.
|
// jsdom lacks matchMedia; sonner's <Toaster> (mounted in AppShell) needs it.
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
window.matchMedia = (query: string) =>
|
window.matchMedia = (query: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user