fix(web): show spinner while deleting session tabs (#2298)
This commit is contained in:
@@ -141,6 +141,43 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Session tabs own their delete control so the X can transition to an
|
||||
* in-place spinner while the delete request is in flight. */
|
||||
.dockview-theme-kandev .session-tab-close-action {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .session-tab-close-action:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .session-tab-close-action:focus-visible {
|
||||
outline: 1px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .dv-tab:hover .session-tab-close-action,
|
||||
.dockview-theme-kandev .dv-active-tab .session-tab-close-action,
|
||||
.dockview-theme-kandev .session-tab-close-action[aria-busy="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .session-tab-close-action:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .dv-locked-groupview .session-tab-close-action {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dockview-theme-kandev .dv-content-container {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { SessionTabCloseAction } from "./session-tab-close-action";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("SessionTabCloseAction", () => {
|
||||
it("renders an operable X and dispatches close when idle", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SessionTabCloseAction sessionId="s1" isDeleting={false} onClose={onClose} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "common:deleteSession" });
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(button.getAttribute("aria-busy")).toBe("false");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a disabled spinner and blocks close while deleting", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SessionTabCloseAction sessionId="s1" isDeleting onClose={onClose} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "common:deleteSession" });
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.getAttribute("aria-busy")).toBe("true");
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
|
||||
type SessionTabCloseActionProps = {
|
||||
sessionId: string | undefined;
|
||||
isDeleting: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SessionTabCloseAction({
|
||||
sessionId,
|
||||
isDeleting,
|
||||
onClose,
|
||||
}: SessionTabCloseActionProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!sessionId) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="session-tab-close-action dv-default-tab-action inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded p-0 text-muted-foreground"
|
||||
data-testid={`session-tab-close-${sessionId}`}
|
||||
aria-label={t("common:deleteSession")}
|
||||
aria-busy={isDeleting}
|
||||
disabled={isDeleting}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isDeleting) onClose();
|
||||
}}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<GridSpinner className="h-3 w-3" />
|
||||
) : (
|
||||
<IconX className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -33,6 +32,8 @@ import { isSessionActive } from "./session-sort";
|
||||
import { resolveSessionTabTitle } from "./session-tab-title";
|
||||
import { TabRenameInput } from "./tab-rename-input";
|
||||
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
|
||||
import { SessionTabCloseAction } from "./session-tab-close-action";
|
||||
import { useSessionTabDelete } from "./use-session-tab-delete";
|
||||
|
||||
function useSessionTabState(sessionId: string | undefined) {
|
||||
const isPrimary = useAppStore((state) => {
|
||||
@@ -237,6 +238,7 @@ function SessionTabTriggerContent({
|
||||
sessionState,
|
||||
isActive,
|
||||
showDeleteOnClose,
|
||||
isDeleting,
|
||||
onCloseTab,
|
||||
}: {
|
||||
props: IDockviewPanelHeaderProps;
|
||||
@@ -248,20 +250,11 @@ function SessionTabTriggerContent({
|
||||
sessionState: TaskSessionState | null;
|
||||
isActive: boolean;
|
||||
showDeleteOnClose: boolean;
|
||||
isDeleting: 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">
|
||||
<div className="flex items-center">
|
||||
{isPrimary && showMultiSessionBadges && (
|
||||
<IconStar className="h-3 w-3 fill-foreground/50 stroke-0 shrink-0 ml-2" />
|
||||
)}
|
||||
@@ -282,11 +275,10 @@ function SessionTabTriggerContent({
|
||||
className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`}
|
||||
/>
|
||||
))}
|
||||
<DockviewDefaultTab
|
||||
{...props}
|
||||
hideClose={!showDeleteOnClose}
|
||||
closeActionOverride={showDeleteOnClose ? onCloseTab : undefined}
|
||||
/>
|
||||
<DockviewDefaultTab {...props} hideClose />
|
||||
{showDeleteOnClose && (
|
||||
<SessionTabCloseAction sessionId={sessionId} isDeleting={isDeleting} onClose={onCloseTab} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -345,6 +337,7 @@ function SessionTabBody({
|
||||
sessionState: TaskSessionState | null;
|
||||
isActive: boolean;
|
||||
showDeleteOnClose: boolean;
|
||||
isDeleting: boolean;
|
||||
onCloseTab: () => void;
|
||||
}) {
|
||||
if (isRenaming) {
|
||||
@@ -408,9 +401,13 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
|
||||
// not deletable, so we omit the X rather than reviving hide-only close behavior.
|
||||
const showDeleteOnClose = showMultiSessionBadges && !!sessionState && isDeletable(sessionState);
|
||||
const { setConfirmDelete, setIsRenaming, setShareOpen } = dialogs;
|
||||
const handleCloseTab = useCallback(() => {
|
||||
setConfirmDelete(true);
|
||||
}, [setConfirmDelete]);
|
||||
const {
|
||||
handleCloseTab,
|
||||
handleDeleteDialogOpenChange,
|
||||
handleConfirmDelete,
|
||||
handleMenuDelete,
|
||||
isDeletingFromTab,
|
||||
} = useSessionTabDelete(setConfirmDelete, actions.handleDelete);
|
||||
const { handlePointerDownCapture, handleKeyDownCapture } = useSessionTabUserActivationIntent(
|
||||
sessionId,
|
||||
activeSessionId,
|
||||
@@ -442,6 +439,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
|
||||
sessionState={sessionState}
|
||||
isActive={isActive}
|
||||
showDeleteOnClose={showDeleteOnClose}
|
||||
isDeleting={isDeletingFromTab}
|
||||
onCloseTab={handleCloseTab}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
@@ -452,7 +450,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
actions={actions}
|
||||
onDelete={() => setConfirmDelete(true)}
|
||||
onDelete={handleMenuDelete}
|
||||
onShare={() => setShareOpen(true)}
|
||||
onHandoffProfile={dialogs.handleHandoffProfile}
|
||||
onStartRename={() => setIsRenaming(true)}
|
||||
@@ -460,10 +458,10 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
|
||||
</ContextMenu>
|
||||
<SessionTabDialogs
|
||||
confirmDelete={dialogs.confirmDelete}
|
||||
setConfirmDelete={setConfirmDelete}
|
||||
setConfirmDelete={handleDeleteDialogOpenChange}
|
||||
isPrimary={isPrimary}
|
||||
sessionCount={sessionCount}
|
||||
onConfirmDelete={actions.handleDelete}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
shareOpen={dialogs.shareOpen}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useSessionTabDelete } from "./use-session-tab-delete";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("useSessionTabDelete", () => {
|
||||
it("uses inline feedback and clears the spinner after a tab delete settles", async () => {
|
||||
const setConfirmDelete = vi.fn();
|
||||
const pending = deferred<boolean>();
|
||||
const handleDelete = vi.fn(() => pending.promise);
|
||||
const { result } = renderHook(() => useSessionTabDelete(setConfirmDelete, handleDelete));
|
||||
|
||||
act(() => result.current.handleCloseTab());
|
||||
expect(setConfirmDelete).toHaveBeenCalledWith(true);
|
||||
|
||||
let deletePromise!: Promise<void>;
|
||||
act(() => {
|
||||
deletePromise = result.current.handleConfirmDelete();
|
||||
});
|
||||
|
||||
expect(result.current.isDeletingFromTab).toBe(true);
|
||||
expect(handleDelete).toHaveBeenCalledWith({ feedback: "inline" });
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(true);
|
||||
await deletePromise;
|
||||
});
|
||||
|
||||
expect(result.current.isDeletingFromTab).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the spinner when a tab delete fails", async () => {
|
||||
const setConfirmDelete = vi.fn();
|
||||
const handleDelete = vi.fn().mockResolvedValue(false);
|
||||
const { result } = renderHook(() => useSessionTabDelete(setConfirmDelete, handleDelete));
|
||||
|
||||
act(() => result.current.handleCloseTab());
|
||||
await act(async () => {
|
||||
await result.current.handleConfirmDelete();
|
||||
});
|
||||
|
||||
expect(result.current.isDeletingFromTab).toBe(false);
|
||||
expect(handleDelete).toHaveBeenCalledWith({ feedback: "inline" });
|
||||
});
|
||||
|
||||
it("keeps context-menu deletion on toast feedback without the tab spinner", async () => {
|
||||
const setConfirmDelete = vi.fn();
|
||||
const handleDelete = vi.fn().mockResolvedValue(true);
|
||||
const { result } = renderHook(() => useSessionTabDelete(setConfirmDelete, handleDelete));
|
||||
|
||||
act(() => result.current.handleMenuDelete());
|
||||
await act(async () => {
|
||||
await result.current.handleConfirmDelete();
|
||||
});
|
||||
|
||||
expect(result.current.isDeletingFromTab).toBe(false);
|
||||
expect(handleDelete).toHaveBeenCalledWith({ feedback: "toast" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoveSessionOptions } from "@/hooks/domains/session/use-session-actions";
|
||||
|
||||
type DeleteOrigin = "tab" | "menu" | null;
|
||||
type SetConfirmDelete = (open: boolean) => void;
|
||||
type HandleDelete = (options?: RemoveSessionOptions) => Promise<boolean>;
|
||||
|
||||
export function useSessionTabDelete(
|
||||
setConfirmDelete: SetConfirmDelete,
|
||||
handleDelete: HandleDelete,
|
||||
) {
|
||||
const [deleteOrigin, setDeleteOrigin] = useState<DeleteOrigin>(null);
|
||||
const [isDeletingFromTab, setIsDeletingFromTab] = useState(false);
|
||||
|
||||
const handleCloseTab = useCallback(() => {
|
||||
setDeleteOrigin("tab");
|
||||
setConfirmDelete(true);
|
||||
}, [setConfirmDelete]);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setConfirmDelete(open);
|
||||
if (!open) setDeleteOrigin(null);
|
||||
},
|
||||
[setConfirmDelete],
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
const isTabDelete = deleteOrigin === "tab";
|
||||
if (isTabDelete) setIsDeletingFromTab(true);
|
||||
|
||||
try {
|
||||
await handleDelete({ feedback: isTabDelete ? "inline" : "toast" });
|
||||
} finally {
|
||||
if (isTabDelete) setIsDeletingFromTab(false);
|
||||
setDeleteOrigin(null);
|
||||
}
|
||||
}, [deleteOrigin, handleDelete]);
|
||||
|
||||
const handleMenuDelete = useCallback(() => {
|
||||
setDeleteOrigin("menu");
|
||||
setConfirmDelete(true);
|
||||
}, [setConfirmDelete]);
|
||||
|
||||
return {
|
||||
handleCloseTab,
|
||||
handleDeleteDialogOpenChange,
|
||||
handleConfirmDelete,
|
||||
handleMenuDelete,
|
||||
isDeletingFromTab,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
test.describe("mobile: session deletion", () => {
|
||||
test("deletes a session from the native session actions sheet", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Mobile session deletion",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
||||
},
|
||||
{ timeout: 30_000, message: "Waiting for the primary session to finish" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const { sessions: initialSessions } = await apiClient.listTaskSessions(task.id);
|
||||
const primarySessionId = initialSessions[0]?.id;
|
||||
if (!primarySessionId) throw new Error("task did not create a primary session");
|
||||
|
||||
const secondarySession = await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
repositoryId: seedData.repositoryId,
|
||||
sessionId: `mobile-delete-${task.id}`,
|
||||
startedAt: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const layout = testPage.locator("[data-testid='mobile-task-layout']:visible");
|
||||
const pill = layout.getByTestId("mobile-sessions-pill");
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await pill.tap();
|
||||
|
||||
const sheet = testPage.getByRole("dialog", { name: "Sessions" });
|
||||
await expect(sheet).toBeVisible({ timeout: 5_000 });
|
||||
const secondaryRow = sheet.getByTestId(`mobile-session-row-${secondarySession.session_id}`);
|
||||
await expect(secondaryRow).toBeVisible();
|
||||
|
||||
// Radix's dropdown trigger is a mouse/click surface even inside the
|
||||
// touch-sized mobile sheet; the surrounding picker and row remain touch-tested.
|
||||
await secondaryRow.getByRole("button", { name: "Session actions" }).click();
|
||||
const actionsMenu = testPage.getByRole("menu");
|
||||
await expect(actionsMenu).toBeVisible({ timeout: 5_000 });
|
||||
await actionsMenu.getByRole("menuitem", { name: "Delete" }).tap();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Delete" }).tap();
|
||||
|
||||
await expect(secondaryRow).not.toBeVisible({ timeout: 15_000 });
|
||||
await expect(sheet.getByTestId(`mobile-session-row-${primarySessionId}`)).toBeVisible();
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
expect(sessions.map((item) => item.id)).toEqual([primarySessionId]);
|
||||
});
|
||||
});
|
||||
@@ -118,9 +118,15 @@ test.describe("Session tab management — close behavior", () => {
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog).toContainText("Delete session?");
|
||||
await dialog.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(
|
||||
testPage.getByTestId("toast-message").filter({ hasText: "Deleting session" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
|
||||
await expect(
|
||||
testPage.getByText("Deleting session successful", { exact: false }),
|
||||
).not.toBeVisible();
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
expect(sessions.map((s) => s.id)).toEqual([session2Id]);
|
||||
|
||||
@@ -16,6 +16,18 @@ const mockClearActiveSession = vi.fn();
|
||||
|
||||
let mockState: Record<string, unknown> = {};
|
||||
|
||||
function resetSessionActionMocks() {
|
||||
vi.clearAllMocks();
|
||||
mockToast.mockReturnValue("toast-1");
|
||||
mockRequest.mockResolvedValue(undefined);
|
||||
mockState = {
|
||||
tasks: { activeSessionId: null },
|
||||
taskSessionsByTask: { itemsByTaskId: {} },
|
||||
setActiveSessionAuto: mockSetActiveSessionAuto,
|
||||
clearActiveSession: mockClearActiveSession,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast, updateToast: mockUpdateToast }),
|
||||
}));
|
||||
@@ -61,17 +73,7 @@ describe("session state predicates", () => {
|
||||
});
|
||||
|
||||
describe("useSessionActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockToast.mockReturnValue("toast-1");
|
||||
mockRequest.mockResolvedValue(undefined);
|
||||
mockState = {
|
||||
tasks: { activeSessionId: null },
|
||||
taskSessionsByTask: { itemsByTaskId: {} },
|
||||
setActiveSessionAuto: mockSetActiveSessionAuto,
|
||||
clearActiveSession: mockClearActiveSession,
|
||||
};
|
||||
});
|
||||
beforeEach(resetSessionActionMocks);
|
||||
|
||||
it("setPrimary dispatches session.set_primary with session id", async () => {
|
||||
const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" }));
|
||||
@@ -104,6 +106,14 @@ describe("useSessionActions", () => {
|
||||
await waitFor(() => expect(onDeleted).toHaveBeenCalled());
|
||||
expect(mockRequest).toHaveBeenCalledWith("session.delete", { session_id: "s1" }, 15000);
|
||||
expect(mockRemoveTaskSession).toHaveBeenCalledWith("t1", "s1");
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Deleting session...",
|
||||
variant: "loading",
|
||||
});
|
||||
expect(mockUpdateToast).toHaveBeenCalledWith("toast-1", {
|
||||
title: "Deleting session successful",
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("remove no-ops when WS request fails (store untouched)", async () => {
|
||||
@@ -161,3 +171,33 @@ describe("useSessionActions", () => {
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionActions inline feedback", () => {
|
||||
beforeEach(resetSessionActionMocks);
|
||||
|
||||
it("removes without progress or success toasts", async () => {
|
||||
const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" }));
|
||||
|
||||
const ok = await result.current.remove({ feedback: "inline" });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
expect(mockUpdateToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows one error toast when deletion fails", async () => {
|
||||
mockRequest.mockRejectedValueOnce(new Error("network down"));
|
||||
const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" }));
|
||||
|
||||
const ok = await result.current.remove({ feedback: "inline" });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(mockToast).toHaveBeenCalledTimes(1);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Deleting session failed",
|
||||
description: "network down",
|
||||
variant: "error",
|
||||
});
|
||||
expect(mockUpdateToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,27 +23,41 @@ type SessionActionsArgs = {
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export type SessionActionFeedback = "toast" | "inline";
|
||||
|
||||
export type RemoveSessionOptions = {
|
||||
feedback?: SessionActionFeedback;
|
||||
};
|
||||
|
||||
type WsActionFn = (
|
||||
action: string,
|
||||
label: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeout?: number,
|
||||
feedback?: SessionActionFeedback,
|
||||
) => Promise<boolean>;
|
||||
|
||||
function useWsAction(): WsActionFn {
|
||||
const { toast, updateToast } = useToast();
|
||||
return useCallback(
|
||||
async (action, label, payload, timeout = 15000) => {
|
||||
async (action, label, payload, timeout = 15000, feedback = "toast") => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return false;
|
||||
const toastId = toast({ title: `${label}...`, variant: "loading" });
|
||||
const toastId =
|
||||
feedback === "toast" ? toast({ title: `${label}...`, variant: "loading" }) : null;
|
||||
try {
|
||||
await client.request(action, payload, timeout);
|
||||
updateToast(toastId, { title: `${label} successful`, variant: "success" });
|
||||
if (toastId) {
|
||||
updateToast(toastId, { title: `${label} successful`, variant: "success" });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
updateToast(toastId, { title: `${label} failed`, description: msg, variant: "error" });
|
||||
if (toastId) {
|
||||
updateToast(toastId, { title: `${label} failed`, description: msg, variant: "error" });
|
||||
} else {
|
||||
toast({ title: `${label} failed`, description: msg, variant: "error" });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -84,29 +98,39 @@ export function useSessionActions({ sessionId, taskId, onDeleted }: SessionActio
|
||||
[sessionId, taskId, wsAction],
|
||||
);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!sessionId || !taskId) return;
|
||||
const ok = await wsAction("session.delete", "Deleting session", { session_id: sessionId });
|
||||
if (!ok) return;
|
||||
const remove = useCallback(
|
||||
async (options: RemoveSessionOptions = {}) => {
|
||||
if (!sessionId || !taskId) return false;
|
||||
const ok = await wsAction(
|
||||
"session.delete",
|
||||
"Deleting session",
|
||||
{ session_id: sessionId },
|
||||
15000,
|
||||
options.feedback,
|
||||
);
|
||||
if (!ok) return false;
|
||||
|
||||
// Switch the active session BEFORE removing from the store so callers
|
||||
// observing activeSessionId don't briefly point at a deleted session.
|
||||
const state = appStoreApi.getState();
|
||||
if (state.tasks.activeSessionId === sessionId) {
|
||||
const sessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? [];
|
||||
const remaining = sessions
|
||||
.filter((s) => s.id !== sessionId)
|
||||
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
||||
if (remaining.length > 0) {
|
||||
state.setActiveSessionAuto(taskId, remaining[0].id);
|
||||
} else {
|
||||
state.clearActiveSession();
|
||||
// Switch the active session BEFORE removing from the store so callers
|
||||
// observing activeSessionId don't briefly point at a deleted session.
|
||||
const state = appStoreApi.getState();
|
||||
if (state.tasks.activeSessionId === sessionId) {
|
||||
const sessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? [];
|
||||
const remaining = sessions
|
||||
.filter((s) => s.id !== sessionId)
|
||||
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
||||
if (remaining.length > 0) {
|
||||
state.setActiveSessionAuto(taskId, remaining[0].id);
|
||||
} else {
|
||||
state.clearActiveSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTaskSession(taskId, sessionId);
|
||||
onDeleted?.();
|
||||
}, [sessionId, taskId, wsAction, removeTaskSession, appStoreApi, onDeleted]);
|
||||
removeTaskSession(taskId, sessionId);
|
||||
onDeleted?.();
|
||||
return true;
|
||||
},
|
||||
[sessionId, taskId, wsAction, removeTaskSession, appStoreApi, onDeleted],
|
||||
);
|
||||
|
||||
return { setPrimary, stop, resume, remove };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"contextCompactions": "Compactions",
|
||||
"createAWorkspaceToConfigureThisIntegration": "Create a workspace to configure this integration.",
|
||||
"dAgo": "{{diffDay}}d ago",
|
||||
"deleteSession": "Delete session",
|
||||
"edit": "Edit",
|
||||
"editTask": "Edit task",
|
||||
"executors": "Executors",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"contextCompactions": "Ćōḿƥàćţĩōńś",
|
||||
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
|
||||
"dAgo": "{{diffDay}}ď àĝō",
|
||||
"deleteSession": "Ďēĺēţē śēśśĩōń",
|
||||
"edit": "Ēďĩţ",
|
||||
"editTask": "Ēďĩţ ţàśķ",
|
||||
"executors": "Ēxēćũţōŕś",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"contextCompactions": "压缩次数",
|
||||
"createAWorkspaceToConfigureThisIntegration": "创建工作区后即可配置此集成。",
|
||||
"dAgo": "{{diffDay}}天前",
|
||||
"deleteSession": "删除会话",
|
||||
"edit": "编辑",
|
||||
"executors": "执行器",
|
||||
"externalMcp": "外部 MCP",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
spec: docs/specs/ui/session-tab-delete-feedback.md
|
||||
created: 2026-08-05
|
||||
status: shipped
|
||||
---
|
||||
|
||||
# Implementation Plan: Session tab delete feedback
|
||||
|
||||
## Overview
|
||||
|
||||
Give the desktop Dockview session-tab close flow local pending feedback while preserving the shared
|
||||
session lifecycle behavior used by context menus and mobile. First make the shared delete action
|
||||
selectively suppress progress/success toasts, then replace Dockview's opaque close icon with a
|
||||
small repository-owned action that can render an X or spinner. Finish with focused desktop and
|
||||
mobile Playwright coverage.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared session delete action
|
||||
|
||||
Update `apps/web/hooks/domains/session/use-session-actions.ts` so `remove` accepts an optional
|
||||
feedback mode and returns whether deletion succeeded. Keep the current loading-to-success toast
|
||||
sequence as the default for existing callers. Add an error-only mode for the tab-X flow: it emits
|
||||
no loading or success toast, but emits one error toast on rejection and leaves store/panel cleanup
|
||||
behind the success result. Existing active-session handoff and `onDeleted` ordering remain intact.
|
||||
|
||||
### Session tab close action
|
||||
|
||||
Add `apps/web/components/task/session-tab-close-action.tsx` as the repository-owned close action.
|
||||
It preserves Dockview's `dv-default-tab-action` class and the existing
|
||||
`session-tab-close-<sessionId>` test ID, prevents the close target from becoming tab-activation
|
||||
intent, and renders either `IconX` or `GridSpinner`. While pending it is disabled, carries
|
||||
`aria-busy`, and retains a localized accessible name.
|
||||
|
||||
Update `apps/web/components/task/session-tab.tsx` to render `DockviewDefaultTab` without its opaque
|
||||
close control and place the new action in the same tab chrome. Track whether the open confirmation
|
||||
came from the X or the context menu. Only confirmed X-originated deletion uses error-only feedback
|
||||
and drives the close spinner; context-menu deletion retains the default toast mode. Cancelling the
|
||||
dialog clears the origin without entering the pending state.
|
||||
|
||||
Add the close action's accessible label to `apps/web/src/locales/en/common.json` and
|
||||
`apps/web/src/locales/zh-cn/common.json`, then regenerate
|
||||
`apps/web/src/locales/pseudo/common.json` with `pnpm run i18n:pseudo`.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- **Desktop outcome:** the session-tab X is the progress surface and remains in the existing tab
|
||||
strip; no overlay or layout change is introduced.
|
||||
- **Mobile entry point:** the existing `MobileSessionsPicker` pill opens `MobilePickerSheet`, and a
|
||||
session row's visible actions menu owns deletion. There is no Dockview X on phone.
|
||||
- **Nearest shipped exemplar:** `apps/web/components/task/mobile/mobile-sessions-section.tsx`
|
||||
remains authoritative for the phone hierarchy, bottom-sheet surface, internal scroll owner,
|
||||
safe-area handling, and touch targets.
|
||||
- **Shared versus specialized behavior:** the delete transport/store mutation remains shared in
|
||||
`useSessionActions`; only the desktop tab-X presentation and feedback mode specialize. Mobile
|
||||
markup and its default feedback mode do not change.
|
||||
- **Parity proof:** a mobile Playwright scenario deletes one of two sessions through the Sessions
|
||||
picker and verifies the selected session disappears while the remaining session stays reachable.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Feedback modes and cleanup:** extend
|
||||
`apps/web/hooks/domains/session/use-session-actions.test.ts` to prove default callers still receive
|
||||
loading/success toasts, error-only deletion receives neither, failures receive one error toast,
|
||||
`remove` reports success/failure, and store/panel cleanup remains success-only.
|
||||
- **Close action state:** add
|
||||
`apps/web/components/task/session-tab-close-action.test.tsx` to prove the idle X is operable, the
|
||||
pending state renders a status spinner with `aria-busy`, and pending activation cannot dispatch a
|
||||
second callback.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN two deletable sessions, WHEN the desktop user clicks a tab X and confirms,
|
||||
THEN the tab/session is removed and no `Deleting session...` or successful-deletion toast appears.
|
||||
**File:** `apps/web/e2e/tests/session/session-tab-management.spec.ts`.
|
||||
- **Scenario:** GIVEN two deletable sessions on a phone viewport, WHEN the user opens the Sessions
|
||||
picker and deletes one through its row action, THEN that row disappears and the remaining session
|
||||
stays reachable. **File:** `apps/web/e2e/tests/session/mobile-session-deletion.spec.ts`, run by
|
||||
the `mobile-chrome` project.
|
||||
|
||||
The transient spinner is covered deterministically by the component test because the real delete
|
||||
response can settle too quickly for a race-free Playwright observation. Playwright covers the
|
||||
integrated no-toast result and session reconciliation.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `rtk pnpm --filter @kandev/web test -- components/task/session-tab-close-action.test.tsx hooks/domains/session/use-session-actions.test.ts` — 2 files, 15 tests passed.
|
||||
- `rtk pnpm run typecheck` — passed.
|
||||
- `rtk pnpm run i18n:check` — passed; pseudo locale is synchronized. The existing catalog audit
|
||||
remains advisory with 670 zh-cn parity notices.
|
||||
- `rtk pnpm run i18n:ratchet` — passed.
|
||||
- `rtk pnpm exec eslint components/task/session-tab.tsx components/task/session-tab-close-action.tsx hooks/domains/session/use-session-actions.ts hooks/domains/session/use-session-actions.test.ts components/task/session-tab-close-action.test.tsx e2e/tests/session/session-tab-management.spec.ts e2e/tests/session/mobile-session-deletion.spec.ts` — passed with no errors or warnings.
|
||||
- `rtk pnpm exec prettier --check ...` — all changed source, test, locale, spec, and plan files
|
||||
passed formatting checks.
|
||||
- `rtk git diff --check` — passed.
|
||||
- `rtk pnpm e2e:run tests/session/session-tab-management.spec.ts -- --grep "tab close button shows delete confirmation"` — 1 desktop test passed with a production build.
|
||||
- `rtk pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-session-deletion.spec.ts` — 1 mobile test passed.
|
||||
- `rtk pnpm e2e:run --no-build tests/session/session-tab-management.spec.ts tests/session/session-tab-close-guard.spec.ts` — 9 desktop tests passed.
|
||||
|
||||
The pseudo-locale artifact updated is `apps/web/src/locales/pseudo/common.json`. Managed E2E runs
|
||||
used isolated temporary backends and cleaned their generated results; no external systems were
|
||||
changed.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-inline-delete-feedback](task-01-inline-delete-feedback.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [task-02-session-deletion-e2e](task-02-session-deletion-e2e.md)
|
||||
|
||||
Execution is sequential in the primary conversation. The E2E task depends on the production and
|
||||
unit-test changes from task 01; these tasks are not parallel-safe.
|
||||
|
||||
## Risks
|
||||
|
||||
- Dockview owns its default X markup, so the custom action must preserve the
|
||||
`dv-default-tab-action` class, pointer suppression, visibility behavior, and activation-intent
|
||||
guard while avoiding a fork of the full tab implementation.
|
||||
- X-origin tracking must be reset on cancel and failure so a later context-menu delete cannot
|
||||
inherit error-only feedback or a stale spinner.
|
||||
- The backend delete response can be faster than a rendered frame; deterministic transient-state
|
||||
proof belongs in the component test rather than a timing-sensitive E2E assertion.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
id: "01-inline-delete-feedback"
|
||||
title: "Inline session delete feedback"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/session-tab-delete-feedback.md"
|
||||
---
|
||||
|
||||
# Task 01: Inline session delete feedback
|
||||
|
||||
## Intent
|
||||
|
||||
Move X-initiated agent-session deletion feedback into the tab close control without changing the
|
||||
confirmation, context-menu feedback, mobile feedback, or successful session cleanup behavior.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Confirmed X-originated deletion renders a disabled, busy spinner in place of the X and emits no
|
||||
progress or success toast; repeated activation cannot dispatch another request.
|
||||
- A failed X-originated deletion keeps the tab/session, restores the X, and emits exactly one error
|
||||
toast. Default session-action callers retain their current toast sequence.
|
||||
- Successful deletion still hands off the active session before removing local session state and
|
||||
the Dockview panel.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/domains/session/use-session-actions.ts`
|
||||
- `apps/web/hooks/domains/session/use-session-actions.test.ts`
|
||||
- `apps/web/components/task/session-tab.tsx`
|
||||
- `apps/web/components/task/session-tab-close-action.tsx`
|
||||
- `apps/web/components/task/session-tab-close-action.test.tsx`
|
||||
- `apps/web/src/locales/en/common.json`
|
||||
- `apps/web/src/locales/zh-cn/common.json`
|
||||
- `apps/web/src/locales/pseudo/common.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The hook contract, tab integration, and close-action state are one behavior and share
|
||||
the same focused tests.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: `What`, `Failure modes`, and the first four `Scenarios`.
|
||||
- Plan: `Shared session delete action`, `Session tab close action`, and `Tests`.
|
||||
- Existing patterns: `useSessionActions` success-only cleanup ordering,
|
||||
`SessionTabTriggerContent`, `shouldMarkSessionTabUserActivationIntent`, and `GridSpinner` in
|
||||
`apps/web/components/enhance-prompt-button.tsx`.
|
||||
|
||||
## Verification
|
||||
|
||||
Bootstrap once if this worktree does not already have dependencies:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- hooks/domains/session/use-session-actions.test.ts components/task/session-tab-close-action.test.tsx
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps/web && pnpm run i18n:check
|
||||
cd apps/web && pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the feedback-mode API, close-control behavior, files changed, exact test results, blockers,
|
||||
risks, and synchronized task/plan status. Do not change backend deletion or other lifecycle-action
|
||||
feedback.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the optional `remove({ feedback })` contract. Default callers retain loading/success/error
|
||||
toasts; the tab-X mode suppresses progress/success and emits one error toast on failure while
|
||||
preserving success-only store/panel cleanup.
|
||||
- Replaced Dockview's opaque session close icon with a localized repository-owned X/spinner action
|
||||
that preserves the close test ID and activation guard. Confirmation origin tracking limits the
|
||||
inline behavior to tab-X deletes; context-menu and mobile callers remain on toast feedback.
|
||||
- Added deterministic idle/pending close-action coverage, including disabled state, `aria-busy`, and
|
||||
blocked repeat activation.
|
||||
- `rtk pnpm --filter @kandev/web test -- components/task/session-tab-close-action.test.tsx hooks/domains/session/use-session-actions.test.ts` — 2 files, 15 tests passed.
|
||||
- `rtk pnpm run typecheck` — passed.
|
||||
- `rtk pnpm run i18n:check` — passed with pseudo locale in sync (the existing 670 zh-cn parity
|
||||
notices are advisory).
|
||||
- `rtk pnpm run i18n:ratchet` — passed.
|
||||
- `rtk pnpm exec eslint components/task/session-tab.tsx components/task/session-tab-close-action.tsx hooks/domains/session/use-session-actions.ts hooks/domains/session/use-session-actions.test.ts components/task/session-tab-close-action.test.tsx e2e/tests/session/session-tab-management.spec.ts e2e/tests/session/mobile-session-deletion.spec.ts` — passed.
|
||||
- `rtk git diff --check` — passed.
|
||||
|
||||
Generated artifact: `apps/web/src/locales/pseudo/common.json`. No external side effects beyond local
|
||||
WebSocket test mocks.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "02-session-deletion-e2e"
|
||||
title: "Session deletion E2E"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-inline-delete-feedback"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/session-tab-delete-feedback.md"
|
||||
---
|
||||
|
||||
# Task 02: Session deletion E2E
|
||||
|
||||
## Intent
|
||||
|
||||
Prove the integrated desktop no-toast deletion result and preserve mobile capability parity through
|
||||
the existing Sessions picker.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The existing desktop X-confirm-delete scenario verifies the selected tab and backend session are
|
||||
removed without a deletion progress or success toast.
|
||||
- A `mobile-chrome` scenario deletes one of two sessions through the Sessions picker row action and
|
||||
verifies the other session remains reachable.
|
||||
- Both scenarios use stable test IDs or scoped accessible controls and assert user-visible outcomes,
|
||||
not API-only behavior.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/session/session-tab-management.spec.ts`
|
||||
- `apps/web/e2e/tests/session/mobile-session-deletion.spec.ts`
|
||||
- `apps/web/e2e/pages/session-page.ts` only if a reusable scoped mobile-session action is needed
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 must be complete so the E2E suite runs against the final production behavior.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential; it depends on task 01 and shares the session-deletion flow.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: the desktop success and mobile parity scenarios.
|
||||
- Plan: `E2E Tests` and `Mobile design contract`.
|
||||
- Existing patterns: `createTaskWithTwoSessions` in
|
||||
`session-tab-management.spec.ts`, `seedTaskSession` in
|
||||
`mobile-multi-repository-session-picker.spec.ts`, and mobile touch interaction via `.tap()`.
|
||||
|
||||
## Verification
|
||||
|
||||
Bootstrap once if this worktree does not already have dependencies:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Run the managed production-build runner:
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run tests/session/session-tab-management.spec.ts -- --grep "tab close button shows delete confirmation"
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-deletion.spec.ts
|
||||
```
|
||||
|
||||
Confirm Playwright discovers one intended test in each focused command before recording the result.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the scenarios added or updated, exact discovered/passed test counts, files changed, generated
|
||||
failure/evidence artifacts if any, cleanup/teardown evidence, blockers, risks, and synchronized
|
||||
task/plan status.
|
||||
|
||||
## Results
|
||||
|
||||
- Extended the desktop close-confirmation scenario to assert that neither the deletion progress nor
|
||||
success toast appears.
|
||||
- Added `mobile-session-deletion.spec.ts`, using the native Sessions picker and scoped row actions
|
||||
to delete one of two sessions while keeping the remaining row reachable.
|
||||
- `rtk pnpm e2e:run tests/session/session-tab-management.spec.ts -- --grep "tab close button shows delete confirmation"` — 1 desktop test passed with a production build.
|
||||
- `rtk pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-session-deletion.spec.ts` — 1 mobile test passed.
|
||||
- `rtk pnpm e2e:run --no-build tests/session/session-tab-management.spec.ts tests/session/session-tab-close-guard.spec.ts` — 9 desktop tests passed.
|
||||
|
||||
The managed runner used isolated temporary backend/database/task data and cleaned its test-results
|
||||
and blob-report directories. The initial exploratory mobile run exposed that the Radix dropdown
|
||||
trigger requires click semantics under the mobile browser; the final test keeps picker/row/dialog
|
||||
touch interactions on `.tap()` and uses `.click()` only for that trigger. No external systems were
|
||||
changed.
|
||||
@@ -148,6 +148,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
| [sidebar-view-creation](ui/sidebar-view-creation.md) | shipped |
|
||||
| [command-panel sidebar task reveal](ui/command-panel-sidebar-task-reveal.md) | draft |
|
||||
| [sidebar-task-completion-icons](ui/sidebar-task-completion-icons.md) | shipped |
|
||||
| [session-tab-delete-feedback](ui/session-tab-delete-feedback.md) | shipped |
|
||||
| [slash-command-composer](ui/slash-command-composer.md) | shipped |
|
||||
| [subagent-observability](ui/subagent-observability.md) | building |
|
||||
| [entity-reference-composer](ui/entity-reference-composer.md) | draft |
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-08-05
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Session tab delete feedback
|
||||
|
||||
## Why
|
||||
|
||||
Deleting an agent session from the task tab currently shows both a progress toast and a success
|
||||
toast. The repeated global notifications are noisy for an action whose initiating control can show
|
||||
its progress directly.
|
||||
|
||||
## What
|
||||
|
||||
- Clicking the X on a deletable agent-session tab continues to open the existing delete
|
||||
confirmation dialog.
|
||||
- After the user confirms, the X is replaced in place by a spinner until the delete request
|
||||
settles. The close action is non-interactive and exposed as busy while the request is pending.
|
||||
- X-initiated deletion does not show progress or success toasts.
|
||||
- Successful deletion keeps the existing behavior: the deleted session and its tab disappear, and
|
||||
another session becomes active when needed.
|
||||
- If deletion fails, the session and tab remain, the spinner returns to the X, and one error toast
|
||||
explains the failure so the user can retry.
|
||||
- Deletion started from the session context menu or a mobile session action keeps its existing
|
||||
feedback behavior.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- A rejected or timed-out delete request does not remove local session state or its Dockview panel.
|
||||
The close action becomes available again and the existing error detail is surfaced in one toast.
|
||||
- Repeated activation while deletion is pending does not dispatch another delete request.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a task with two deletable agent sessions, **WHEN** the user clicks one tab's X and
|
||||
confirms deletion, **THEN** that X shows a spinner without progress or success toasts until the
|
||||
session and tab are removed.
|
||||
- **GIVEN** an X-initiated delete request is pending, **WHEN** the user attempts to activate the
|
||||
close control again, **THEN** no duplicate delete request is dispatched.
|
||||
- **GIVEN** an X-initiated delete request fails, **WHEN** the request settles, **THEN** the session
|
||||
tab remains, the X becomes available again, and one error toast is shown.
|
||||
- **GIVEN** the user cancels the delete confirmation, **WHEN** the dialog closes, **THEN** the tab
|
||||
remains unchanged and no spinner or deletion toast appears.
|
||||
- **GIVEN** a phone viewport, **WHEN** the user deletes a session from the Sessions picker, **THEN**
|
||||
the mobile flow remains reachable and removes the selected session without relying on a desktop
|
||||
tab X.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Removing or redesigning the delete confirmation dialog.
|
||||
- Changing backend session-deletion semantics, active-session selection, or Dockview reconciliation.
|
||||
- Replacing feedback for context-menu, mobile, stop, resume, or set-primary actions.
|
||||
Reference in New Issue
Block a user