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 && (
+
+ )}