fix: clear stale archived state when unarchiving from task detail top bar (#1816)
This commit is contained in:
@@ -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> = {}): 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> = {}): KanbanTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "Restored task",
|
||||
workflowStepId: "step-1",
|
||||
position: 0,
|
||||
state: "TODO",
|
||||
...overrides,
|
||||
} as KanbanTask;
|
||||
}
|
||||
|
||||
function baseParams(overrides: Partial<Parameters<typeof buildDebugEntries>[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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
Reference in New Issue
Block a user