+
{isPrimary && showMultiSessionBadges && (
)}
@@ -282,11 +275,10 @@ function SessionTabTriggerContent({
className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`}
/>
))}
-
+
+ {showDeleteOnClose && (
+
+ )}
);
}
@@ -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}
/>
@@ -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) {
() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((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();
+ const handleDelete = vi.fn(() => pending.promise);
+ const { result } = renderHook(() => useSessionTabDelete(setConfirmDelete, handleDelete));
+
+ act(() => result.current.handleCloseTab());
+ expect(setConfirmDelete).toHaveBeenCalledWith(true);
+
+ let deletePromise!: Promise;
+ 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" });
+ });
+});
diff --git a/apps/web/components/task/use-session-tab-delete.ts b/apps/web/components/task/use-session-tab-delete.ts
new file mode 100644
index 0000000000..9c04c98034
--- /dev/null
+++ b/apps/web/components/task/use-session-tab-delete.ts
@@ -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;
+
+export function useSessionTabDelete(
+ setConfirmDelete: SetConfirmDelete,
+ handleDelete: HandleDelete,
+) {
+ const [deleteOrigin, setDeleteOrigin] = useState(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,
+ };
+}
diff --git a/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts b/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts
new file mode 100644
index 0000000000..b004dc7212
--- /dev/null
+++ b/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts
@@ -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]);
+ });
+});
diff --git a/apps/web/e2e/tests/session/session-tab-management.spec.ts b/apps/web/e2e/tests/session/session-tab-management.spec.ts
index ecaba20630..d75a24ee11 100644
--- a/apps/web/e2e/tests/session/session-tab-management.spec.ts
+++ b/apps/web/e2e/tests/session/session-tab-management.spec.ts
@@ -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]);
diff --git a/apps/web/hooks/domains/session/use-session-actions.test.ts b/apps/web/hooks/domains/session/use-session-actions.test.ts
index 48a037a62e..dcc26057af 100644
--- a/apps/web/hooks/domains/session/use-session-actions.test.ts
+++ b/apps/web/hooks/domains/session/use-session-actions.test.ts
@@ -16,6 +16,18 @@ const mockClearActiveSession = vi.fn();
let mockState: Record = {};
+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();
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-session-actions.ts b/apps/web/hooks/domains/session/use-session-actions.ts
index 1124af35e8..180bc5f2b2 100644
--- a/apps/web/hooks/domains/session/use-session-actions.ts
+++ b/apps/web/hooks/domains/session/use-session-actions.ts
@@ -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,
timeout?: number,
+ feedback?: SessionActionFeedback,
) => Promise;
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 };
}
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index ded7c4b894..d1cb629f50 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -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",
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 77c82ebaac..7e163961af 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -38,6 +38,7 @@
"contextCompactions": "Ćōḿƥàćţĩōńś",
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
"dAgo": "{{diffDay}}ď àĝō",
+ "deleteSession": "Ďēĺēţē śēśśĩōń",
"edit": "Ēďĩţ",
"editTask": "Ēďĩţ ţàśķ",
"executors": "Ēxēćũţōŕś",
diff --git a/apps/web/src/locales/zh-cn/common.json b/apps/web/src/locales/zh-cn/common.json
index ca80684ed7..ada3261df5 100644
--- a/apps/web/src/locales/zh-cn/common.json
+++ b/apps/web/src/locales/zh-cn/common.json
@@ -39,6 +39,7 @@
"contextCompactions": "压缩次数",
"createAWorkspaceToConfigureThisIntegration": "创建工作区后即可配置此集成。",
"dAgo": "{{diffDay}}天前",
+ "deleteSession": "删除会话",
"edit": "编辑",
"executors": "执行器",
"externalMcp": "外部 MCP",
diff --git a/docs/plans/session-tab-delete-feedback/plan.md b/docs/plans/session-tab-delete-feedback/plan.md
new file mode 100644
index 0000000000..ad1839706e
--- /dev/null
+++ b/docs/plans/session-tab-delete-feedback/plan.md
@@ -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-` 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.
diff --git a/docs/plans/session-tab-delete-feedback/task-01-inline-delete-feedback.md b/docs/plans/session-tab-delete-feedback/task-01-inline-delete-feedback.md
new file mode 100644
index 0000000000..4ae89637d1
--- /dev/null
+++ b/docs/plans/session-tab-delete-feedback/task-01-inline-delete-feedback.md
@@ -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.
diff --git a/docs/plans/session-tab-delete-feedback/task-02-session-deletion-e2e.md b/docs/plans/session-tab-delete-feedback/task-02-session-deletion-e2e.md
new file mode 100644
index 0000000000..2cf2656735
--- /dev/null
+++ b/docs/plans/session-tab-delete-feedback/task-02-session-deletion-e2e.md
@@ -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.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 52a620fb18..951a5b6a83 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -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 |
diff --git a/docs/specs/ui/session-tab-delete-feedback.md b/docs/specs/ui/session-tab-delete-feedback.md
new file mode 100644
index 0000000000..40eb1185e3
--- /dev/null
+++ b/docs/specs/ui/session-tab-delete-feedback.md
@@ -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.