fix(web): show spinner while deleting session tabs (#2298)

This commit is contained in:
Carlos Florêncio
2026-08-05 23:05:33 +01:00
committed by GitHub
parent 6faa883069
commit da66f88570
18 changed files with 814 additions and 58 deletions
+37
View File
@@ -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>
);
}
+21 -23
View File
@@ -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 };
}
+1
View File
@@ -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",
+1
View File
@@ -38,6 +38,7 @@
"contextCompactions": "Ćōḿƥàćţĩōńś",
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
"dAgo": "{{diffDay}}ď àĝō",
"deleteSession": "Ďēĺēţē śēśśĩōń",
"edit": "Ēďĩţ",
"editTask": "Ēďĩţ ţàśķ",
"executors": "Ēxēćũţōŕś",
+1
View File
@@ -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.
+1
View File
@@ -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.