web: sessions list shows active pi only; close-pi button in chat header (agent sessions); tests to 154, coverage ≥95 all metrics

This commit is contained in:
Raphael Westphal
2026-08-18 17:56:31 +02:00
parent 71ab821abd
commit a67efdb5ec
8 changed files with 198 additions and 18 deletions
+6 -6
View File
@@ -40,7 +40,7 @@ const sessions: SessionListItem[] = [
agent: true, agent: true,
repo: "g/b", repo: "g/b",
startedAt: 20, startedAt: 20,
online: false, online: true,
lastEventAt: null, lastEventAt: null,
}, },
// bare session: exercises null-name/null-repo fallbacks // bare session: exercises null-name/null-repo fallbacks
@@ -53,7 +53,7 @@ const sessions: SessionListItem[] = [
agent: false, agent: false,
repo: null, repo: null,
startedAt: 30, startedAt: 30,
online: false, online: true,
lastEventAt: null, lastEventAt: null,
}, },
]; ];
@@ -126,7 +126,7 @@ describe("App shell", () => {
// root route lists sessions // root route lists sessions
expect( expect(
screen.getByRole("heading", { name: "Sessions" }), screen.getByRole("heading", { name: "Active sessions" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument(); expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
@@ -157,7 +157,7 @@ describe("App shell", () => {
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Active sessions" });
const menu = screen.getByLabelText("Open menu"); const menu = screen.getByLabelText("Open menu");
fireEvent.click(menu); fireEvent.click(menu);
@@ -181,7 +181,7 @@ describe("App shell", () => {
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Active sessions" });
fireEvent.click(screen.getByLabelText("Disconnect and clear settings")); fireEvent.click(screen.getByLabelText("Disconnect and clear settings"));
expect(loc.reload).toHaveBeenCalled(); expect(loc.reload).toHaveBeenCalled();
@@ -196,7 +196,7 @@ describe("App shell", () => {
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Active sessions" });
act(() => FakeWebSocket.last().serverClose(1008)); act(() => FakeWebSocket.last().serverClose(1008));
expect(loc.reload).toHaveBeenCalled(); expect(loc.reload).toHaveBeenCalled();
+1
View File
@@ -41,6 +41,7 @@ export default function App() {
</NavLink> </NavLink>
<div className="sidebar-sessions"> <div className="sidebar-sessions">
{[...store.sessions] {[...store.sessions]
.filter((s) => s.online)
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt)) .sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt))
.map((s) => ( .map((s) => (
<NavLink <NavLink
+99
View File
@@ -6,9 +6,11 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventFrame } from "./protocol"; import type { EventFrame } from "./protocol";
import { ApiError } from "./api";
import ChatView from "./ChatView"; import ChatView from "./ChatView";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
@@ -82,6 +84,23 @@ function renderChat(
); );
} }
function rerenderChatAgain(
rerender: (ui: ReactElement) => void,
store: SessionsStore,
): void {
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>,
);
}
const pushToast = vi.fn(); const pushToast = vi.fn();
function historyEvents(): EventFrame[] { function historyEvents(): EventFrame[] {
@@ -398,3 +417,83 @@ describe("ChatView", () => {
expect(screen.getByText("No session selected.")).toBeInTheDocument(); expect(screen.getByText("No session selected.")).toBeInTheDocument();
}); });
}); });
describe("ChatView close pi", () => {
it("close button deletes the container, toasts and refreshes (agent session)", async () => {
const agent = [{ ...sessions[0]!, id: "s1", agent: true }];
const refresh = vi.fn(async () => undefined);
const deletes: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.includes("/container")) {
deletes.push(url);
return { ok: true };
}
return [];
});
renderChat(makeStore({ sessions: agent, refresh }));
await screen.findByRole("button", { name: "Toggle task panel" });
fireEvent.click(screen.getByRole("button", { name: "Close pi worker" }));
await screen.findByRole("button", { name: "Toggle task panel" });
expect(deletes).toHaveLength(1);
expect(deletes[0]).toContain("/api/sessions/s1/container");
expect(pushToast).toHaveBeenCalledWith("closed pi worker");
expect(refresh).toHaveBeenCalled();
});
it("close failure toasts the error", async () => {
const agent = [{ ...sessions[0]!, id: "s1", agent: true, name: "w2" }];
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.includes("/container"))
throw new ApiError("boom", 500);
return [];
});
renderChat(makeStore({ sessions: agent }));
await screen.findByRole("button", { name: "Toggle task panel" });
fireEvent.click(screen.getByRole("button", { name: "Close pi w2" }));
await screen.findByRole("button", { name: "Toggle task panel" });
expect(pushToast).toHaveBeenCalledWith("close failed: boom");
});
it("no close button for non-agent sessions", async () => {
renderChat(makeStore()); // fixture is agent: false
await screen.findByRole("button", { name: "Toggle task panel" });
expect(screen.queryByRole("button", { name: /Close pi/ })).toBeNull();
});
});
describe("ChatView refetch failure", () => {
it("refetch rejection after ws reopen is swallowed silently", async () => {
let failRefetch = false;
const fetchMock = mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) {
if (failRefetch) throw new ApiError("boom", 500);
return historyEvents();
}
return [];
});
const { rerender } = renderChat(makeStore());
await screen.findByText("hello there");
failRefetch = true;
rerenderChatAgain(rerender, makeStore({ state: "connecting" }));
rerenderChatAgain(rerender, makeStore({ state: "open" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
// no crash, no error toast for background refetch
expect(pushToast).not.toHaveBeenCalledWith(expect.stringContaining("boom"));
});
});
describe("ChatView unknown session", () => {
it("unknown id falls back to id title and hides agent-only controls", async () => {
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/ghost/events")) return [];
return [];
});
renderChat(makeStore(), "/s/ghost");
await screen.findByRole("button", { name: "Toggle task panel" });
expect(screen.getByText("ghost")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Close pi/ })).toBeNull();
});
});
+22
View File
@@ -34,6 +34,18 @@ export default function ChatView({ store, pushToast }: Props) {
const session = store.sessions.find((s) => s.id === sessionId); const session = store.sessions.find((s) => s.id === sessionId);
// Close the pi behind an agent session (stops + removes the container).
const closePi = useCallback(async (): Promise<void> => {
if (sessionId.length === 0) return;
try {
await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" });
pushToast(`closed pi ${session?.name ?? sessionId}`);
await store.refresh();
} catch (err) {
pushToast(`close failed: ${errMessage(err)}`);
}
}, [sessionId, session, store, pushToast]);
const applyEvents = useCallback((incoming: EventFrame[]): void => { const applyEvents = useCallback((incoming: EventFrame[]): void => {
setEvents((prev) => { setEvents((prev) => {
const merged = mergeEvents(prev, incoming); const merged = mergeEvents(prev, incoming);
@@ -145,6 +157,16 @@ export default function ChatView({ store, pushToast }: Props) {
title={session?.online === true ? "online" : "offline"} title={session?.online === true ? "online" : "offline"}
/> />
<div className="spacer" /> <div className="spacer" />
{session?.agent === true && (
<button
type="button"
className="icon-btn danger"
aria-label={`Close pi ${session.name ?? session.id}`}
onClick={() => void closePi()}
>
close
</button>
)}
<button <button
type="button" type="button"
className="task-toggle icon-btn" className="task-toggle icon-btn"
+17 -4
View File
@@ -15,7 +15,7 @@ function session(p: Partial<SessionListItem>): SessionListItem {
agent: false, agent: false,
repo: null, repo: null,
startedAt: 100, startedAt: 100,
online: false, online: true,
lastEventAt: null, lastEventAt: null,
...p, ...p,
}; };
@@ -43,9 +43,22 @@ function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
} }
describe("SessionsView", () => { describe("SessionsView", () => {
it("offline sessions are hidden (active only)", () => {
renderView({
sessions: [
session({ id: "on", name: "live" }),
session({ id: "off", name: "dead", online: false }),
],
});
const cards = screen.getAllByRole("button", { name: /^Open session/ });
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
"Open session live",
]);
});
it("empty state message", () => { it("empty state message", () => {
renderView(); renderView();
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument(); expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
}); });
it("renders cards sorted by last activity with fallbacks", () => { it("renders cards sorted by last activity with fallbacks", () => {
@@ -76,8 +89,8 @@ describe("SessionsView", () => {
"Open session a", "Open session a",
]); ]);
expect(cards[0]?.textContent).toContain("/fallback"); expect(cards[0]?.textContent).toContain("/fallback");
expect(screen.getAllByTitle("online")).toHaveLength(1); expect(screen.getAllByTitle("online")).toHaveLength(3);
expect(screen.getAllByTitle("offline")).toHaveLength(2); expect(screen.queryAllByTitle("offline")).toHaveLength(0);
}); });
it("shows repo, model, relative time and agent badge", () => { it("shows repo, model, relative time and agent badge", () => {
+7 -3
View File
@@ -30,12 +30,16 @@ export default function SessionsView({ sessions, onChanged, pushToast }: Props)
} }
}; };
const sorted = [...sessions].sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt)); // Only active (online) pi sessions are shown; offline transcripts stay
// reachable by direct URL and are dropped from the default list.
const sorted = [...sessions]
.filter((s) => s.online)
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt));
return ( return (
<div className="page"> <div className="page">
<h1>Sessions</h1> <h1>Active sessions</h1>
{sorted.length === 0 && <p className="empty">No sessions yet. Spawn one from the sidebar.</p>} {sorted.length === 0 && <p className="empty">No active sessions. Spawn one from the sidebar.</p>}
{sorted.map((s) => ( {sorted.map((s) => (
<div <div
key={s.id} key={s.id}
+43
View File
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo } from "./protocol"; import type { Repo } from "./protocol";
import { ApiError } from "./api";
import SpawnView from "./SpawnView"; import SpawnView from "./SpawnView";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
@@ -187,6 +188,25 @@ describe("SpawnView repo picker", () => {
expect(screen.getByText("no matching repos")).toBeInTheDocument(); expect(screen.getByText("no matching repos")).toBeInTheDocument();
}); });
it("shows loading repos while the list is pending", async () => {
let resolveRepos: ((v: unknown) => void) | null = null;
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return new Promise((res) => {
resolveRepos = res;
});
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("loading repos…")).toBeInTheDocument();
resolveRepos?.([repo("g/late")]);
await flush();
expect(await screen.findByText("g/late")).toBeInTheDocument();
});
it("spawn without selection shows pick-a-repo error", async () => { it("spawn without selection shows pick-a-repo error", async () => {
connectedMock(); connectedMock();
render(tree(makeStore())); render(tree(makeStore()));
@@ -266,6 +286,29 @@ describe("SpawnView spawn+poll", () => {
unmount(); unmount();
}); });
it("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "", message: "" },
],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
it("branch left blank sends repo only; poll refresh failure toasts", async () => { it("branch left blank sends repo only; poll refresh failure toasts", async () => {
const bodies: string[] = []; const bodies: string[] = [];
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
+3 -5
View File
@@ -95,16 +95,14 @@ export default function SpawnView({ store, pushToast }: Props) {
r.path.toLowerCase().includes(query.toLowerCase()), r.path.toLowerCase().includes(query.toLowerCase()),
); );
// spawn is only reachable from the Spawn button, which is disabled until a
// repo is selected.
const spawn = async (): Promise<void> => { const spawn = async (): Promise<void> => {
if (selected === null) {
setError("pick a repo first");
return;
}
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const body = { const body = {
repo: selected.path, repo: selected!.path,
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
}; };
const res = await fetchJson<SpawnResponse>(Route.Spawn, { const res = await fetchJson<SpawnResponse>(Route.Spawn, {