From 623044a8d4cd052dc14a3974ab556e99193f576c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 30 May 2026 22:53:49 +0100 Subject: [PATCH] feat(web): confirm session delete when closing multi-session agent tab (#1174) --- apps/web/components/task/session-tab.tsx | 107 ++++++++++++++---- apps/web/e2e/pages/session-page.ts | 5 + .../session/session-tab-management.spec.ts | 59 ++++++++++ 3 files changed, 147 insertions(+), 24 deletions(-) diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 9b83ad404e..fbc20845bc 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { IconStar } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; @@ -218,6 +218,70 @@ function SessionContextMenuItems({ ); } +function SessionTabTriggerContent({ + props, + sessionId, + isPrimary, + showMultiSessionBadges, + sessionNumber, + agentName, + sessionState, + isActive, + showDeleteOnClose, + onCloseTab, +}: { + props: IDockviewPanelHeaderProps; + sessionId: string | undefined; + isPrimary: boolean; + showMultiSessionBadges: boolean; + sessionNumber: number | null; + agentName: string | null; + sessionState: TaskSessionState | null; + isActive: boolean; + showDeleteOnClose: boolean; + onCloseTab: () => void; +}) { + const tabContentRef = useRef(null); + + useEffect(() => { + if (!showDeleteOnClose || !sessionId) return; + const closeAction = tabContentRef.current?.querySelector(".dv-default-tab-action"); + if (!closeAction) return; + closeAction.setAttribute("data-testid", `session-tab-close-${sessionId}`); + return () => closeAction.removeAttribute("data-testid"); + }, [showDeleteOnClose, sessionId, isActive]); // isActive: re-run when tab activates so Dockview renders .dv-default-tab-action + + return ( +
+ {isPrimary && showMultiSessionBadges && ( + + )} + {sessionNumber != null && showMultiSessionBadges && ( + + {sessionNumber} + + )} + {agentName && + (isSessionActive(sessionState) ? ( + + ) : ( + + ))} + +
+ ); +} + /** * Custom dockview tab for session panels. * Shows agent logo, index badge, and star for primary; right-click for lifecycle actions. @@ -244,6 +308,12 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { }, [agentLabel, api]); const showMultiSessionBadges = sessionCount > 1; + // Multi-session tab close means delete, not hide-only. Running/starting sessions are + // not deletable, so we omit the X rather than reviving hide-only close behavior. + const showDeleteOnClose = showMultiSessionBadges && !!sessionState && isDeletable(sessionState); + const handleCloseTab = useCallback(() => { + setConfirmDelete(true); + }, []); return ( <> @@ -253,29 +323,18 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { data-testid={sessionId ? `session-tab-${sessionId}` : undefined} onDoubleClick={onDoubleClick} > -
- {isPrimary && showMultiSessionBadges && ( - - )} - {sessionNumber != null && showMultiSessionBadges && ( - - {sessionNumber} - - )} - {agentName && - (isSessionActive(sessionState) ? ( - - ) : ( - - ))} - -
+ { const tab = this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`); diff --git a/apps/web/e2e/tests/session/session-tab-management.spec.ts b/apps/web/e2e/tests/session/session-tab-management.spec.ts index b53e5d2a87..47fbd55955 100644 --- a/apps/web/e2e/tests/session/session-tab-management.spec.ts +++ b/apps/web/e2e/tests/session/session-tab-management.spec.ts @@ -95,6 +95,65 @@ function starInTab(session: SessionPage, sessionId: string) { } test.describe("Session tab management — close behavior", () => { + test("tab close button shows delete confirmation and removes session on confirm", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions( + testPage, + apiClient, + seedData, + "Tab Close Deletes Session", + ); + + await session.sessionTabBySessionId(session1Id).click(); + await expect(session.sessionTabCloseButton(session1Id)).toBeVisible({ timeout: 5_000 }); + + await session.sessionTabCloseButton(session1Id).click(); + + const dialog = session.alertDialog(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText("Delete session?"); + await dialog.getByRole("button", { name: "Delete" }).click(); + + await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 }); + await expect(session.sessionTabBySessionId(session2Id)).toBeVisible(); + + const { sessions } = await apiClient.listTaskSessions(task.id); + expect(sessions.map((s) => s.id)).toEqual([session2Id]); + }); + + test("tab close button delete confirmation can be cancelled", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const { task, session, session1Id } = await createTaskWithTwoSessions( + testPage, + apiClient, + seedData, + "Tab Close Cancel Delete", + ); + + await session.sessionTabBySessionId(session1Id).click(); + await expect(session.sessionTabCloseButton(session1Id)).toBeVisible({ timeout: 5_000 }); + await session.sessionTabCloseButton(session1Id).click(); + + const dialog = session.alertDialog(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + await expect(session.sessionTabBySessionId(session1Id)).toBeVisible(); + const { sessions } = await apiClient.listTaskSessions(task.id); + expect(sessions).toHaveLength(2); + }); + test("deleting a non-active session removes its tab and stays gone", async ({ testPage, apiClient,