diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 1763f7a36e..e752c925d7 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -304,27 +304,35 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea const handleArchiveTask = useCallback( async (taskId: string) => { + // Capture active state before the async API call — the WS "task.updated" + // handler removes the archived task from kanbans before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; try { await archiveTaskById(taskId); - await removeTaskFromBoard(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } catch (error) { console.error("Failed to archive task:", error); } }, - [archiveTaskById, removeTaskFromBoard], + [archiveTaskById, removeTaskFromBoard, store], ); const handleDeleteTask = useCallback( async (taskId: string) => { setDeletingTaskId(taskId); + // Capture active state before the async API call — the WS "task.deleted" + // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; try { await deleteTaskById(taskId); - await removeTaskFromBoard(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } finally { setDeletingTaskId(null); } }, - [deleteTaskById, removeTaskFromBoard], + [deleteTaskById, removeTaskFromBoard, store], ); const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index e57cdead6e..6ff5546eab 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -271,27 +271,35 @@ function useSidebarActions(store: StoreApi) { const handleArchiveTask = useCallback( async (taskId: string) => { + // Capture active state before the async API call — the WS "task.updated" + // handler removes the archived task from kanbans before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; try { await archiveTaskById(taskId); - await removeTaskFromBoard(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } catch (error) { console.error("Failed to archive task:", error); } }, - [archiveTaskById, removeTaskFromBoard], + [archiveTaskById, removeTaskFromBoard, store], ); const handleDeleteTask = useCallback( async (taskId: string) => { setDeletingTaskId(taskId); + // Capture active state before the async API call — the WS "task.deleted" + // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; try { await deleteTaskById(taskId); - await removeTaskFromBoard(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } finally { setDeletingTaskId(null); } }, - [deleteTaskById, removeTaskFromBoard], + [deleteTaskById, removeTaskFromBoard, store], ); const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index ed9d0e50eb..f33e2833ed 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -68,6 +68,28 @@ export class SessionPage { return this.chat.getByText("Plan mode", { exact: true }); } + /** + * Delete a task via the sidebar context menu. + * Hovers to reveal the menu trigger, opens it, and clicks "Delete". + */ + async deleteTaskInSidebar(title: string): Promise { + const taskRow = this.sidebar.locator('[role="button"]').filter({ hasText: title }); + await taskRow.hover(); + await taskRow.getByRole("button", { name: "Task actions" }).click(); + await this.page.getByRole("menuitem", { name: "Delete" }).click(); + } + + /** + * Archive a task via the sidebar context menu. + * Hovers to reveal the menu trigger, opens it, and clicks "Archive". + */ + async archiveTaskInSidebar(title: string): Promise { + const taskRow = this.sidebar.locator('[role="button"]').filter({ hasText: title }); + await taskRow.hover(); + await taskRow.getByRole("button", { name: "Task actions" }).click(); + await this.page.getByRole("menuitem", { name: "Archive" }).click(); + } + stepperStep(name: string): Locator { return this.page.getByTestId(`workflow-step-${name}`); } diff --git a/apps/web/e2e/tests/archive-task-redirect.spec.ts b/apps/web/e2e/tests/archive-task-redirect.spec.ts new file mode 100644 index 0000000000..6c7a8582d9 --- /dev/null +++ b/apps/web/e2e/tests/archive-task-redirect.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Archive task redirect", () => { + /** + * Verifies that archiving a task from the sidebar correctly redirects: + * - When other tasks remain: switches to the next available task's session + * and the chat panel shows that task's agent conversation. + * - When no tasks remain: redirects to the home page. + */ + test("archiving active task redirects to remaining task, archiving last task redirects home", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + // Use distinct descriptions so we can verify the chat panel switches content. + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Archive Task A", + seedData.agentProfileId, + { + description: 'e2e:message("alpha archive response")', + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Archive Task B", + seedData.agentProfileId, + { + description: 'e2e:message("beta archive response")', + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + // --- Navigate to kanban and wait for both task cards --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const cardA = kanban.taskCardByTitle("Archive Task A"); + const cardB = kanban.taskCardByTitle("Archive Task B"); + await expect(cardA).toBeVisible({ timeout: 30_000 }); + await expect(cardB).toBeVisible({ timeout: 30_000 }); + + // Click task A to open its session detail page + await cardA.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Verify we're viewing Task A's session — the chat shows its agent response. + // Use .last() because the response text also appears inside the user message + // prompt text (e2e:message("alpha archive response")). + await expect(session.chat.getByText("alpha archive response").last()).toBeVisible({ + timeout: 30_000, + }); + + // Both tasks should be visible in the sidebar + await expect(session.taskInSidebar("Archive Task A")).toBeVisible({ timeout: 15_000 }); + await expect(session.taskInSidebar("Archive Task B")).toBeVisible({ timeout: 15_000 }); + + // Record the current URL so we can verify it changes after archival + const urlBeforeArchive = testPage.url(); + + // --- Archive the active task (A) — should switch to task B --- + await session.archiveTaskInSidebar("Archive Task A"); + + // Task A should disappear from sidebar + await expect(session.taskInSidebar("Archive Task A")).not.toBeVisible({ timeout: 15_000 }); + + // Task B should still be visible in sidebar + await expect(session.taskInSidebar("Archive Task B")).toBeVisible({ timeout: 10_000 }); + + // The chat panel should now show Task B's session content + await expect(session.chat.getByText("beta archive response").last()).toBeVisible({ + timeout: 15_000, + }); + + // The URL should have changed to Task B's session (still a /s/ route, but different ID) + await expect(testPage).toHaveURL(/\/s\//, { timeout: 10_000 }); + expect(testPage.url()).not.toBe(urlBeforeArchive); + + // --- Archive the last remaining task (B) — should redirect home --- + await session.archiveTaskInSidebar("Archive Task B"); + + // Should redirect to the home page + await expect(testPage).not.toHaveURL(/\/s\//, { timeout: 15_000 }); + }); +}); diff --git a/apps/web/e2e/tests/delete-task-redirect.spec.ts b/apps/web/e2e/tests/delete-task-redirect.spec.ts new file mode 100644 index 0000000000..c33dfe9bc0 --- /dev/null +++ b/apps/web/e2e/tests/delete-task-redirect.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Delete task redirect", () => { + /** + * Verifies that deleting a task from the sidebar correctly redirects: + * - When other tasks remain: switches to the next available task's session + * and the chat panel shows that task's agent conversation. + * - When no tasks remain: redirects to the home page. + * + * This tests the fix for a race condition where the WS "task.deleted" + * handler would clear activeTaskId before removeTaskFromBoard could check + * it, causing the user to stay on a stale/deleted task page. + */ + test("deleting active task redirects to remaining task, deleting last task redirects home", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + // Use distinct descriptions so we can verify the chat panel switches content. + // The mock agent echoes e2e:message() text back, giving each task a unique + // response we can assert on. + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Delete Task A", + seedData.agentProfileId, + { + description: 'e2e:message("alpha session response")', + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Delete Task B", + seedData.agentProfileId, + { + description: 'e2e:message("beta session response")', + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + // --- Navigate to kanban and wait for both task cards --- + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const cardA = kanban.taskCardByTitle("Delete Task A"); + const cardB = kanban.taskCardByTitle("Delete Task B"); + await expect(cardA).toBeVisible({ timeout: 30_000 }); + await expect(cardB).toBeVisible({ timeout: 30_000 }); + + // Click task A to open its session detail page + await cardA.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Verify we're viewing Task A's session — the chat shows its agent response. + // Use .last() because "alpha session response" also appears inside the user + // message prompt text (e2e:message("alpha session response")). + await expect(session.chat.getByText("alpha session response").last()).toBeVisible({ + timeout: 30_000, + }); + + // Both tasks should be visible in the sidebar + await expect(session.taskInSidebar("Delete Task A")).toBeVisible({ timeout: 15_000 }); + await expect(session.taskInSidebar("Delete Task B")).toBeVisible({ timeout: 15_000 }); + + // Record the current URL so we can verify it changes after deletion + const urlBeforeDelete = testPage.url(); + + // --- Delete the active task (A) — should switch to task B --- + await session.deleteTaskInSidebar("Delete Task A"); + + // Task A should disappear from sidebar + await expect(session.taskInSidebar("Delete Task A")).not.toBeVisible({ timeout: 15_000 }); + + // Task B should still be visible in sidebar + await expect(session.taskInSidebar("Delete Task B")).toBeVisible({ timeout: 10_000 }); + + // The chat panel should now show Task B's session content + await expect(session.chat.getByText("beta session response").last()).toBeVisible({ + timeout: 15_000, + }); + + // The URL should have changed to Task B's session (still a /s/ route, but different ID) + await expect(testPage).toHaveURL(/\/s\//, { timeout: 10_000 }); + expect(testPage.url()).not.toBe(urlBeforeDelete); + + // --- Delete the last remaining task (B) — should redirect home --- + await session.deleteTaskInSidebar("Delete Task B"); + + // Should redirect to the home page + await expect(testPage).not.toHaveURL(/\/s\//, { timeout: 15_000 }); + }); +}); diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts index 08a7b1265b..b9c87c6470 100644 --- a/apps/web/hooks/use-task-removal.ts +++ b/apps/web/hooks/use-task-removal.ts @@ -12,6 +12,17 @@ type TaskRemovalOptions = { useLayoutSwitch?: boolean; }; +type RemoveFromBoardOptions = { + /** + * The active task ID captured **before** the async delete API call. + * Avoids a race with the WS "task.deleted" handler that may clear + * activeTaskId before removeTaskFromBoard runs. + */ + wasActiveTaskId?: string | null; + /** The active session ID captured before the async delete API call. */ + wasActiveSessionId?: string | null; +}; + /** * Hook that provides shared logic for removing a task from the kanban board * (after archive or delete) and switching to the next available task. @@ -100,9 +111,13 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp /** * Remove a task from the kanban board state (both single and multi snapshots) * and switch to the next available task if the removed task was active. + * + * Pass `opts.wasActiveTaskId` / `opts.wasActiveSessionId` when calling after + * an async API call (e.g. deleteTaskById) — the WS "task.deleted" handler may + * clear activeTaskId before this function runs. */ const removeTaskFromBoard = useCallback( - async (taskId: string) => { + async (taskId: string, opts?: RemoveFromBoardOptions) => { removeTaskFromSnapshots(taskId); // Collect remaining tasks across snapshots @@ -114,11 +129,20 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp allRemainingTasks.push(...store.getState().kanban.tasks); } - // If removed task was active, switch to another task or go home - const currentActiveTaskId = store.getState().tasks.activeTaskId; - if (currentActiveTaskId !== taskId) return; + // Use the caller-provided active task ID (captured before the async API + // call) to avoid the race with the WS handler that may have already + // cleared it. Fall back to the current store value for callers that + // don't provide it (e.g. archive, which doesn't go through the API). + const activeTaskId = + opts?.wasActiveTaskId !== undefined + ? opts.wasActiveTaskId + : store.getState().tasks.activeTaskId; + if (activeTaskId !== taskId) return; - const oldSessionId = store.getState().tasks.activeSessionId; + const oldSessionId = + opts?.wasActiveSessionId !== undefined + ? opts.wasActiveSessionId + : store.getState().tasks.activeSessionId; if (allRemainingTasks.length > 0) { await switchToNextTask(allRemainingTasks[0], oldSessionId); } else {