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,
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user