fix: clear stale archived state when unarchiving from task detail top bar (#1816)

This commit is contained in:
dk-blackfuel
2026-07-20 17:05:10 +02:00
committed by GitHub
parent ace3f56475
commit d185f13d13
4 changed files with 193 additions and 65 deletions
@@ -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 -64
View File
@@ -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}`));
});