feat(web): confirm session delete when closing multi-session agent tab (#1174)

This commit is contained in:
Carlos Florêncio
2026-05-30 22:53:49 +01:00
committed by GitHub
parent 0966b71f1c
commit 623044a8d4
3 changed files with 147 additions and 24 deletions
+83 -24
View File
@@ -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<HTMLDivElement>(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 (
<div ref={tabContentRef} className="flex items-center">
{isPrimary && showMultiSessionBadges && (
<IconStar className="h-3 w-3 fill-foreground/50 stroke-0 shrink-0 ml-2" />
)}
{sessionNumber != null && showMultiSessionBadges && (
<span className="ml-1.5 text-[11px] font-medium leading-none text-muted-foreground bg-foreground/10 rounded px-1.5 py-0.5">
{sessionNumber}
</span>
)}
{agentName &&
(isSessionActive(sessionState) ? (
<GridSpinner
className={`ml-1.5 shrink-0 text-[14px] text-muted-foreground${isActive ? "" : " opacity-50"}`}
/>
) : (
<AgentLogo
agentName={agentName}
size={14}
className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`}
/>
))}
<DockviewDefaultTab
{...props}
hideClose={!showDeleteOnClose}
closeActionOverride={showDeleteOnClose ? onCloseTab : undefined}
/>
</div>
);
}
/**
* 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}
>
<div className="flex items-center">
{isPrimary && showMultiSessionBadges && (
<IconStar className="h-3 w-3 fill-foreground/50 stroke-0 shrink-0 ml-2" />
)}
{sessionNumber != null && showMultiSessionBadges && (
<span className="ml-1.5 text-[11px] font-medium leading-none text-muted-foreground bg-foreground/10 rounded px-1.5 py-0.5">
{sessionNumber}
</span>
)}
{agentName &&
(isSessionActive(sessionState) ? (
<GridSpinner
className={`ml-1.5 shrink-0 text-[14px] text-muted-foreground${isActive ? "" : " opacity-50"}`}
/>
) : (
<AgentLogo
agentName={agentName}
size={14}
className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`}
/>
))}
<DockviewDefaultTab {...props} hideClose={sessionCount <= 1} />
</div>
<SessionTabTriggerContent
props={props}
sessionId={sessionId}
isPrimary={isPrimary}
showMultiSessionBadges={showMultiSessionBadges}
sessionNumber={sessionNumber}
agentName={agentName}
sessionState={sessionState}
isActive={isActive}
showDeleteOnClose={showDeleteOnClose}
onCloseTab={handleCloseTab}
/>
</ContextMenuTrigger>
<SessionContextMenuItems
sessionState={sessionState}
+5
View File
@@ -1033,6 +1033,11 @@ export class SessionPage {
return this.page.getByTestId(`session-tab-${sessionId}`);
}
/** Dockview close (X) button inside a session tab. */
sessionTabCloseButton(sessionId: string): Locator {
return this.page.getByTestId(`session-tab-close-${sessionId}`);
}
/** Context menu on a dockview tab — right-click the tab to trigger it. */
async rightClickTab(text: string): Promise<void> {
const tab = this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`);
@@ -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,