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:
@@ -40,7 +40,7 @@ const sessions: SessionListItem[] = [
|
||||
agent: true,
|
||||
repo: "g/b",
|
||||
startedAt: 20,
|
||||
online: false,
|
||||
online: true,
|
||||
lastEventAt: null,
|
||||
},
|
||||
// bare session: exercises null-name/null-repo fallbacks
|
||||
@@ -53,7 +53,7 @@ const sessions: SessionListItem[] = [
|
||||
agent: false,
|
||||
repo: null,
|
||||
startedAt: 30,
|
||||
online: false,
|
||||
online: true,
|
||||
lastEventAt: null,
|
||||
},
|
||||
];
|
||||
@@ -126,7 +126,7 @@ describe("App shell", () => {
|
||||
|
||||
// root route lists sessions
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Sessions" }),
|
||||
screen.getByRole("heading", { name: "Active sessions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
|
||||
|
||||
@@ -157,7 +157,7 @@ describe("App shell", () => {
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByRole("heading", { name: "Sessions" });
|
||||
await screen.findByRole("heading", { name: "Active sessions" });
|
||||
|
||||
const menu = screen.getByLabelText("Open menu");
|
||||
fireEvent.click(menu);
|
||||
@@ -181,7 +181,7 @@ describe("App shell", () => {
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
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"));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
@@ -196,7 +196,7 @@ describe("App shell", () => {
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByRole("heading", { name: "Sessions" });
|
||||
await screen.findByRole("heading", { name: "Active sessions" });
|
||||
|
||||
act(() => FakeWebSocket.last().serverClose(1008));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function App() {
|
||||
</NavLink>
|
||||
<div className="sidebar-sessions">
|
||||
{[...store.sessions]
|
||||
.filter((s) => s.online)
|
||||
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt))
|
||||
.map((s) => (
|
||||
<NavLink
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactElement } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EventFrame } from "./protocol";
|
||||
import { ApiError } from "./api";
|
||||
import ChatView from "./ChatView";
|
||||
import type { SessionsStore } from "./store";
|
||||
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();
|
||||
|
||||
function historyEvents(): EventFrame[] {
|
||||
@@ -398,3 +417,83 @@ describe("ChatView", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,18 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
|
||||
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 => {
|
||||
setEvents((prev) => {
|
||||
const merged = mergeEvents(prev, incoming);
|
||||
@@ -145,6 +157,16 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
title={session?.online === true ? "online" : "offline"}
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
className="task-toggle icon-btn"
|
||||
|
||||
@@ -15,7 +15,7 @@ function session(p: Partial<SessionListItem>): SessionListItem {
|
||||
agent: false,
|
||||
repo: null,
|
||||
startedAt: 100,
|
||||
online: false,
|
||||
online: true,
|
||||
lastEventAt: null,
|
||||
...p,
|
||||
};
|
||||
@@ -43,9 +43,22 @@ function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
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", () => {
|
||||
@@ -76,8 +89,8 @@ describe("SessionsView", () => {
|
||||
"Open session a",
|
||||
]);
|
||||
expect(cards[0]?.textContent).toContain("/fallback");
|
||||
expect(screen.getAllByTitle("online")).toHaveLength(1);
|
||||
expect(screen.getAllByTitle("offline")).toHaveLength(2);
|
||||
expect(screen.getAllByTitle("online")).toHaveLength(3);
|
||||
expect(screen.queryAllByTitle("offline")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows repo, model, relative time and agent badge", () => {
|
||||
|
||||
@@ -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 (
|
||||
<div className="page">
|
||||
<h1>Sessions</h1>
|
||||
{sorted.length === 0 && <p className="empty">No sessions yet. Spawn one from the sidebar.</p>}
|
||||
<h1>Active sessions</h1>
|
||||
{sorted.length === 0 && <p className="empty">No active sessions. Spawn one from the sidebar.</p>}
|
||||
{sorted.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Repo } from "./protocol";
|
||||
import { ApiError } from "./api";
|
||||
import SpawnView from "./SpawnView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
@@ -187,6 +188,25 @@ describe("SpawnView repo picker", () => {
|
||||
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 () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
@@ -266,6 +286,29 @@ describe("SpawnView spawn+poll", () => {
|
||||
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 () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
|
||||
@@ -95,16 +95,14 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
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> => {
|
||||
if (selected === null) {
|
||||
setError("pick a repo first");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = {
|
||||
repo: selected.path,
|
||||
repo: selected!.path,
|
||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||
};
|
||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||
|
||||
Reference in New Issue
Block a user