From a67efdb5ec6fc9641ed2f8f84bd226f6875046e3 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 17:56:31 +0200 Subject: [PATCH] =?UTF-8?q?web:=20sessions=20list=20shows=20active=20pi=20?= =?UTF-8?q?only;=20close-pi=20button=20in=20chat=20header=20(agent=20sessi?= =?UTF-8?q?ons);=20tests=20to=20154,=20coverage=20=E2=89=A595=20all=20metr?= =?UTF-8?q?ics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.test.tsx | 12 ++--- web/src/App.tsx | 1 + web/src/ChatView.test.tsx | 99 +++++++++++++++++++++++++++++++++++ web/src/ChatView.tsx | 22 ++++++++ web/src/SessionsView.test.tsx | 21 ++++++-- web/src/SessionsView.tsx | 10 ++-- web/src/SpawnView.test.tsx | 43 +++++++++++++++ web/src/SpawnView.tsx | 8 ++- 8 files changed, 198 insertions(+), 18 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 86c3d44..089fb3a 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 9f8dd87..eeaf843 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,6 +41,7 @@ export default function App() {
{[...store.sessions] + .filter((s) => s.online) .sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt)) .map((s) => ( void, + store: SessionsStore, +): void { + rerender( + + + } + /> + OTHER
} /> + + , + ); +} + 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(); + }); +}); diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index c0ff227..0a19f8b 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -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 => { + 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"} />
+ {session?.agent === true && ( + + )}