feat(web): confirm session delete when closing multi-session agent tab (#1174)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user