diff --git a/apps/web/components/task/task-page-content-helpers.test.ts b/apps/web/components/task/task-page-content-helpers.test.ts index ad0ef00ace..d1b477f3fc 100644 --- a/apps/web/components/task/task-page-content-helpers.test.ts +++ b/apps/web/components/task/task-page-content-helpers.test.ts @@ -1,13 +1,48 @@ import { describe, expect, it, vi } from "vitest"; import type { Task } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; import { + buildArchivedValue, buildDebugEntries, hasResolvedTaskDetails, + resolveEffectiveTask, resolveTaskContentState, resolveTaskProps, syncActiveTaskSession, } from "./task-page-content-helpers"; +type KanbanTask = KanbanState["tasks"][number]; + +function makeArchivedTaskDetails(overrides: Partial = {}): Task { + return { + id: "task-1", + title: "Archived task", + description: "", + workflow_step_id: "step-1", + position: 0, + state: "TODO", + workspace_id: "ws-1", + workflow_id: "wf-1", + priority: 0, + repositories: [], + created_at: "", + updated_at: "2026-07-19T00:00:00Z", + archived_at: "2026-07-19T00:00:00Z", + ...overrides, + } as Task; +} + +function makeKanbanTask(overrides: Partial = {}): KanbanTask { + return { + id: "task-1", + title: "Restored task", + workflowStepId: "step-1", + position: 0, + state: "TODO", + ...overrides, + } as KanbanTask; +} + function baseParams(overrides: Partial[0]> = {}) { return { connectionStatus: "connected", @@ -185,3 +220,41 @@ describe("syncActiveTaskSession", () => { expect(setActiveSessionAuto).not.toHaveBeenCalled(); }); }); + +describe("resolveEffectiveTask archived state", () => { + // Regression: unarchiving from the detail top bar publishes + // task.updated(archived_at=null) which re-adds the task to the kanban, but + // the one-shot fetchTask details still carry the old archived_at. The + // resolved task must reflect the live kanban entry so the top bar stops + // showing the archived UI / Unarchive button — otherwise the task "never + // comes back" after a successful unarchive. + it("clears a stale archived_at when the task is present in the kanban", () => { + const taskDetails = makeArchivedTaskDetails(); + const kanbanTask = makeKanbanTask(); + + const resolved = resolveEffectiveTask(taskDetails, null, kanbanTask, "task-1"); + + expect(resolved?.archived_at).toBeNull(); + expect(buildArchivedValue(resolved, null).isArchived).toBe(false); + }); + + it("keeps archived_at when the task is absent from the kanban (still archived)", () => { + const taskDetails = makeArchivedTaskDetails(); + + const resolved = resolveEffectiveTask(taskDetails, null, null, "task-1"); + + expect(resolved?.archived_at).toBe("2026-07-19T00:00:00Z"); + expect(buildArchivedValue(resolved, null).isArchived).toBe(true); + }); + + it("prefers live kanban title/state while preserving base-only fields", () => { + const taskDetails = makeArchivedTaskDetails({ archived_at: null }); + const kanbanTask = makeKanbanTask({ title: "Live title", state: "IN_PROGRESS" }); + + const resolved = resolveEffectiveTask(taskDetails, null, kanbanTask, "task-1"); + + expect(resolved?.title).toBe("Live title"); + expect(resolved?.state).toBe("IN_PROGRESS"); + expect(resolved?.workspace_id).toBe("ws-1"); + }); +}); diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index 7faabfbdc9..e172d97866 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -1,4 +1,11 @@ -import type { Repository, Task } from "@/lib/types/http"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Repository, + type Task, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; import { issueFieldsFromMetadata } from "@/lib/metadata-utils"; type ACPDebugInfo = { @@ -93,6 +100,77 @@ export function deriveIsAgentWorking( return isAgentRunning && (taskState === "IN_PROGRESS" || taskState === "SCHEDULING"); } +/** + * Resolve the task the detail view should render, layering the freshest of + * three sources: the one-shot `fetchTask` details, the SSR/`initialTask`, and + * the live kanban entry. The base (details/initial) carries fields the kanban + * doesn't (repositories, timestamps); the kanban carries live board state. + */ +export function resolveEffectiveTask( + taskDetails: Task | null, + initialTask: Task | null, + kanbanTask: KanbanState["tasks"][number] | null, + effectiveTaskId: string | null, +): Task | null { + const matchingTaskDetails = taskDetails?.id === effectiveTaskId ? taskDetails : null; + const matchingInitialTask = initialTask?.id === effectiveTaskId ? initialTask : null; + const baseTask = matchingTaskDetails ?? matchingInitialTask; + + if (!baseTask && !kanbanTask) return null; + if (baseTask) return mergeBaseWithKanban(baseTask, kanbanTask); + if (kanbanTask) return buildTaskFromKanban(kanbanTask, taskDetails, initialTask); + return null; +} + +export function mergeBaseWithKanban( + baseTask: Task, + kanbanTask: KanbanState["tasks"][number] | null, +): Task { + if (!kanbanTask) return baseTask; + return { + ...baseTask, + title: kanbanTask.title ?? baseTask.title, + description: kanbanTask.description ?? baseTask.description, + workflow_step_id: + (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id, + position: kanbanTask.position ?? baseTask.position, + state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state, + repositories: baseTask.repositories, + // A task present in the kanban board is by definition not archived: the + // board never holds archived tasks, and unarchive re-adds it via + // task.updated(archived_at=null). The one-shot `fetchTask` that seeds + // baseTask only refetches on task-id change, so it keeps a stale + // archived_at after an in-place unarchive from the detail top bar — + // trust the live kanban entry instead. Without this the top bar keeps + // showing the Unarchive button / archived banner and the task "never + // comes back" even though the backend restored it. + archived_at: null, + }; +} + +export function buildTaskFromKanban( + kanbanTask: KanbanState["tasks"][number], + taskDetails: Task | null, + initialTask: Task | null, +): Task { + const prevWorkspaceId = taskDetails?.workspace_id ?? initialTask?.workspace_id; + const prevBoardId = taskDetails?.workflow_id ?? initialTask?.workflow_id; + return { + id: toTaskId(kanbanTask.id), + title: kanbanTask.title, + description: kanbanTask.description ?? "", + workflow_step_id: kanbanTask.workflowStepId, + position: kanbanTask.position, + state: kanbanTask.state ?? "CREATED", + workspace_id: prevWorkspaceId ?? toWorkspaceId(""), + workflow_id: prevBoardId ?? toWorkflowId(""), + priority: 0, + repositories: [], + created_at: "", + updated_at: kanbanTask.updatedAt ?? "", + }; +} + export function buildArchivedValue(task: Task | null, repository: Repository | null) { const isArchived = !!task?.archived_at; return { diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index d3209c0199..66f9122ab1 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -2,14 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { IconAlertTriangle } from "@tabler/icons-react"; -import { - taskId as toTaskId, - workflowId as toWorkflowId, - workspaceId as toWorkspaceId, - type Repository, - type RepositoryScript, - type Task, -} from "@/lib/types/http"; +import type { Repository, RepositoryScript, Task } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import type { KanbanState } from "@/lib/state/slices"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; @@ -27,6 +20,7 @@ import { deriveIsAgentWorking, buildArchivedValue, hasResolvedTaskDetails, + resolveEffectiveTask, resolveTaskContentState, syncActiveTaskSession, } from "@/components/task/task-page-content-helpers"; @@ -45,62 +39,6 @@ type TaskPageContentProps = { officeTaskHref?: string | null; }; -function resolveEffectiveTask( - taskDetails: Task | null, - initialTask: Task | null, - kanbanTask: KanbanState["tasks"][number] | null, - effectiveTaskId: string | null, -): Task | null { - const matchingTaskDetails = taskDetails?.id === effectiveTaskId ? taskDetails : null; - const matchingInitialTask = initialTask?.id === effectiveTaskId ? initialTask : null; - const baseTask = matchingTaskDetails ?? matchingInitialTask; - - if (!baseTask && !kanbanTask) return null; - if (baseTask) return mergeBaseWithKanban(baseTask, kanbanTask); - if (kanbanTask) return buildTaskFromKanban(kanbanTask, taskDetails, initialTask); - return null; -} - -function mergeBaseWithKanban( - baseTask: Task, - kanbanTask: KanbanState["tasks"][number] | null, -): Task { - if (!kanbanTask) return baseTask; - return { - ...baseTask, - title: kanbanTask.title ?? baseTask.title, - description: kanbanTask.description ?? baseTask.description, - workflow_step_id: - (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id, - position: kanbanTask.position ?? baseTask.position, - state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state, - repositories: baseTask.repositories, - }; -} - -function buildTaskFromKanban( - kanbanTask: KanbanState["tasks"][number], - taskDetails: Task | null, - initialTask: Task | null, -): Task { - const prevWorkspaceId = taskDetails?.workspace_id ?? initialTask?.workspace_id; - const prevBoardId = taskDetails?.workflow_id ?? initialTask?.workflow_id; - return { - id: toTaskId(kanbanTask.id), - title: kanbanTask.title, - description: kanbanTask.description ?? "", - workflow_step_id: kanbanTask.workflowStepId, - position: kanbanTask.position, - state: kanbanTask.state ?? "CREATED", - workspace_id: prevWorkspaceId ?? toWorkspaceId(""), - workflow_id: prevBoardId ?? toWorkflowId(""), - priority: 0, - repositories: [], - created_at: "", - updated_at: kanbanTask.updatedAt ?? "", - }; -} - export function useWorkflowStepsMapped() { const kanbanSteps = useAppStore((state) => state.kanban.steps); return useMemo( diff --git a/apps/web/e2e/tests/task/unarchive-detail-topbar.spec.ts b/apps/web/e2e/tests/task/unarchive-detail-topbar.spec.ts new file mode 100644 index 0000000000..a17e937a1b --- /dev/null +++ b/apps/web/e2e/tests/task/unarchive-detail-topbar.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "../../fixtures/test-base"; + +// Regression: unarchiving from the task-detail top bar must clear the archived +// UI *in place*. The detail view seeds `archived_at` from a one-shot fetchTask +// that only re-fetches when the task id changes. Unarchive publishes +// task.updated(archived_at=null), which re-adds the task to the kanban, and the +// task resolver must trust that live entry over the stale fetch. Before the fix +// the merge kept the stale archived_at, so the Unarchive button stayed and the +// task "never came back" even though the backend restored it and the toast said +// success. +test("unarchiving from the detail top bar clears the archived UI in place", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.createTask(seedData.workspaceId, "Unarchive in place", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.archiveTask(task.id); + + await testPage.goto(`/t/${task.id}`); + + const unarchiveButton = testPage.getByTestId("task-unarchive-button"); + await expect(unarchiveButton).toBeVisible(); + + const responsePromise = testPage.waitForResponse((response) => + response.url().endsWith(`/api/v1/tasks/${task.id}/unarchive`), + ); + await unarchiveButton.click(); + await responsePromise; + + await expect(testPage.getByText("Task unarchived")).toBeVisible(); + // The Unarchive button only renders while isArchived is true, so its removal + // proves the detail view observed the unarchive without a navigation/refetch. + await expect(unarchiveButton).toHaveCount(0); + // No redirect away — unarchive keeps the user on the same task route. + await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`)); +});