diff --git a/apps/web/app/dockview-theme.css b/apps/web/app/dockview-theme.css index 4d1c0d2163..76efca585c 100644 --- a/apps/web/app/dockview-theme.css +++ b/apps/web/app/dockview-theme.css @@ -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); } diff --git a/apps/web/components/task/session-tab-close-action.test.tsx b/apps/web/components/task/session-tab-close-action.test.tsx new file mode 100644 index 0000000000..eb5077e248 --- /dev/null +++ b/apps/web/components/task/session-tab-close-action.test.tsx @@ -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(); + + 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(); + + 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(); + }); +}); diff --git a/apps/web/components/task/session-tab-close-action.tsx b/apps/web/components/task/session-tab-close-action.tsx new file mode 100644 index 0000000000..11ad160fdc --- /dev/null +++ b/apps/web/components/task/session-tab-close-action.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 3ceb098db5..41d4d3725b 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -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(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 ( -
+
{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.