feat: show progress while archiving tasks (#3775)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-22 20:54:00 +01:00
committed by GitHub
parent 4f76997c8a
commit 8a6a3c1e61
23 changed files with 622 additions and 32 deletions
+5 -9
View File
@@ -3,13 +3,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "@/lib/routing/client-router";
import type { PaginationState } from "@tanstack/react-table";
import {
archiveTask,
deleteTask,
listTasksByWorkspace,
unarchiveTask,
updateUserSettings,
} from "@/lib/api";
import { deleteTask, listTasksByWorkspace, unarchiveTask, updateUserSettings } from "@/lib/api";
import type { DeleteTaskParams } from "@/lib/api/domains/kanban-api";
import type { Task, Workspace, Workflow, Repository } from "@/lib/types/http";
import { useToast } from "@/components/toast-provider";
@@ -26,6 +20,7 @@ import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskListFacets } from "@/hooks/use-task-list-facets";
import { useTaskListFacetSelection } from "@/hooks/use-task-list-facet-selection";
import { useTaskActions } from "@/hooks/use-task-actions";
import { linkToTask } from "@/lib/links";
import { unarchiveToastPayload } from "@/lib/tasks/unarchive-feedback";
import { isTaskDeleteDirtyWorktreeError } from "@/lib/api/task-delete-errors";
@@ -165,12 +160,13 @@ function errorDescription(err: unknown): string {
function useTaskMutations(fetchTasks: () => void) {
const { toast } = useToast();
const { archiveTaskById } = useTaskActions();
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
const handleArchive = useCallback(
async (taskId: string, opts?: { cascade?: boolean }) => {
try {
await archiveTask(taskId, opts);
await archiveTaskById(taskId, opts);
toast({
title: t("tasks:taskArchived"),
description: t("tasks:taskArchivedDescription"),
@@ -184,7 +180,7 @@ function useTaskMutations(fetchTasks: () => void) {
});
}
},
[fetchTasks, toast],
[archiveTaskById, fetchTasks, toast],
);
const handleUnarchive = useCallback(
@@ -181,7 +181,7 @@ describe("Threads pending archive lifecycle", () => {
"b",
"c",
]);
expect(transport.toast).not.toHaveBeenCalled();
expect(transport.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "loading" }));
} finally {
await act(async () => {
pending.resolve();
+1 -1
View File
@@ -150,7 +150,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
function ToastList({ toasts }: { toasts: Toast[] }) {
return (
<div
className="fixed bottom-[calc(1rem+var(--app-status-bar-height))] right-4 z-[60] flex w-[360px] flex-col-reverse gap-2"
className="fixed bottom-[calc(1rem+var(--app-status-bar-height))] right-4 z-[60] flex w-[calc(100vw-2rem)] max-w-[360px] flex-col-reverse gap-2"
data-testid="toast-container"
aria-live="polite"
aria-relevant="additions text"
@@ -5,12 +5,14 @@ test("phone picker shows archive progress and restores failed archive", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
prCapture,
}) => {
await testPage.setViewportSize({ width: 320, height: 568 });
await checkImmediateArchive({
page: testPage,
api: apiClient,
seed: seedData,
mobile: true,
screenshotPath: testInfo.outputPath("pending-archive.png"),
prCapture,
});
});
@@ -1,15 +1,16 @@
import { expect, type Page, type Route } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import type { PrAssetCapture } from "../../helpers/pr-asset-capture";
import { SessionPage } from "../../pages/session-page";
// @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3
// @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3, AC-TASKS-REMOVAL-NAVIGATION-004.1, AC-TASKS-REMOVAL-NAVIGATION-004.2, AC-TASKS-REMOVAL-NAVIGATION-004.3
export async function checkImmediateArchive(options: {
page: Page;
api: ApiClient;
seed: SeedData;
mobile: boolean;
screenshotPath: string;
prCapture: PrAssetCapture;
}) {
const { page, api, seed, mobile } = options;
const { settings } = await api.getUserSettings();
@@ -27,6 +28,8 @@ export async function checkImmediateArchive(options: {
await page.route(path, handler);
const session = new SessionPage(page);
const sheet = page.getByRole("dialog", { name: "Tasks", exact: true });
const progressToast = () =>
page.getByTestId("toast-message").filter({ hasText: "Archiving in progress" });
const rows = () => (mobile ? sheet : session.sidebar).getByTestId("sidebar-task-item");
const targetRow = () => rows().filter({ hasText: "Archive immediately" });
const press = async (locator: ReturnType<Page["getByTestId"]>) => {
@@ -55,10 +58,42 @@ export async function checkImmediateArchive(options: {
await openPicker();
await expect(targetRow()).toBeVisible();
expect(pending).toBeNull();
await expect(progressToast()).toHaveCount(0);
await openArchive();
await press(page.getByTestId("archive-task-confirm"));
await expect.poll(() => pending !== null).toBe(true);
await expect(progressToast()).toBeVisible();
await expect(progressToast().locator("svg")).toHaveClass(/animate-spin/);
await expect(page.getByTestId("toast-container")).toHaveAttribute("aria-live", "polite");
await expect(progressToast()).toBeInViewport();
await expect(page.getByText("Kandev update available", { exact: true })).toBeHidden();
await expect
.poll(async () => {
const currentViewport = page.viewportSize();
const currentBox = await progressToast().boundingBox();
if (!currentViewport || !currentBox) return null;
return Math.round(currentViewport.width - currentBox.x - currentBox.width);
})
.toBe(16);
const viewport = page.viewportSize();
const toastBox = await progressToast().boundingBox();
expect(viewport).not.toBeNull();
expect(toastBox).not.toBeNull();
expect(toastBox!.x).toBeGreaterThanOrEqual(0);
expect(viewport!.width - toastBox!.x - toastBox!.width).toBeGreaterThanOrEqual(0);
expect(viewport!.width - toastBox!.x - toastBox!.width).toBeLessThanOrEqual(24);
expect(viewport!.height - toastBox!.y - toastBox!.height).toBeGreaterThanOrEqual(0);
expect(viewport!.height - toastBox!.y - toastBox!.height).toBeLessThanOrEqual(80);
await options.prCapture.screenshot(
mobile ? "archive-progress-mobile" : "archive-progress-desktop",
{
caption: mobile
? "A phone task archive stays visibly in progress while the request is pending."
: "A desktop task archive stays visibly in progress while the request is pending.",
fullPage: true,
},
);
if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap();
await expect(targetRow()).toBeVisible();
await expect(targetRow()).toHaveAttribute("aria-busy", "true");
@@ -66,7 +101,7 @@ export async function checkImmediateArchive(options: {
await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible();
await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport();
await expect(page).toHaveURL(new RegExp(`/t/${nav.task_id}$`));
await (mobile ? sheet : session.sidebar).screenshot({ path: options.screenshotPath });
await expect(progressToast()).toBeVisible();
await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport();
await pending!.fulfill({
status: 503,
@@ -74,6 +109,7 @@ export async function checkImmediateArchive(options: {
body: JSON.stringify({ error: "Archive unavailable" }),
});
pending = null;
await expect(progressToast()).toHaveCount(0);
if (mobile) {
await openPicker();
} else {
@@ -87,12 +123,14 @@ export async function checkImmediateArchive(options: {
await openArchive();
await press(page.getByTestId("archive-task-confirm"));
await expect.poll(() => pending !== null).toBe(true);
await expect(progressToast()).toBeVisible();
if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap();
await expect(targetRow()).toBeVisible();
await expect(targetRow()).toHaveAttribute("aria-busy", "true");
await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible();
await pending!.continue();
pending = null;
await expect(progressToast()).toHaveCount(0);
await expect(
page.getByTestId("toast-message").filter({ hasText: "Archived 1 task." }),
).toBeVisible();
@@ -5,12 +5,13 @@ test("sidebar shows archive progress before removal and recovers on failure", as
testPage,
apiClient,
seedData,
}, testInfo) => {
prCapture,
}) => {
await checkImmediateArchive({
page: testPage,
api: apiClient,
seed: seedData,
mobile: false,
screenshotPath: testInfo.outputPath("pending-archive.png"),
prCapture,
});
});
+105 -3
View File
@@ -11,6 +11,9 @@ const replaceTaskUrlMock = vi.fn();
const setActiveSessionMock = vi.fn();
const setActiveTaskMock = vi.fn();
const toastMock = vi.fn();
const dismissToastMock = vi.fn();
const archiveFailureMessage = "archive failed";
const archiveProgressToastId = "archive-progress-toast";
let storeState: {
tasks: { activeTaskId: string | null; activeSessionId: string | null };
setActiveSession: (...args: unknown[]) => void;
@@ -40,7 +43,7 @@ vi.mock("@/hooks/use-task-removal", () => ({
}),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock }),
useToast: () => ({ toast: toastMock, dismissToast: dismissToastMock }),
}));
import {
@@ -51,6 +54,7 @@ import {
beforeEach(() => {
vi.clearAllMocks();
toastMock.mockReturnValue(archiveProgressToastId);
storeState = {
tasks: { activeTaskId: "task-A", activeSessionId: "sess-A" },
setActiveSession: (...args: unknown[]) => setActiveSessionMock(...args),
@@ -112,11 +116,13 @@ describe("useArchiveAndSwitchTask", () => {
});
it("does not restore a task when the coordinator rejects before mutation", async () => {
const error = new Error("archive failed");
const error = new Error(archiveFailureMessage);
runTaskRemovalMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useArchiveAndSwitchTask());
await expect(result.current("task-A", { cascade: true })).rejects.toThrow("archive failed");
await expect(result.current("task-A", { cascade: true })).rejects.toThrow(
archiveFailureMessage,
);
expect(setActiveSessionMock).not.toHaveBeenCalled();
expect(setActiveTaskMock).not.toHaveBeenCalled();
@@ -153,6 +159,102 @@ describe("useDeleteAndSwitchTask", () => {
});
describe("useTaskActions", () => {
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.1, AC-TASKS-REMOVAL-NAVIGATION-004.2
it("keeps a loading toast visible until an archive request resolves", async () => {
let resolveArchive!: () => void;
archiveTaskMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveArchive = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const request = result.current.archiveTaskById("task-1");
expect(toastMock).toHaveBeenCalledWith({
title: "Archiving in progress",
variant: "loading",
});
expect(dismissToastMock).not.toHaveBeenCalled();
resolveArchive();
await request;
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.2
it("dismisses the loading toast when an archive request rejects", async () => {
archiveTaskMock.mockRejectedValueOnce(new Error(archiveFailureMessage));
const { result } = renderHook(() => useTaskActions());
await expect(result.current.archiveTaskById("task-1")).rejects.toThrow(archiveFailureMessage);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.3
it("coalesces concurrent archive requests into one loading toast", async () => {
let resolveFirst!: () => void;
let resolveSecond!: () => void;
archiveTaskMock
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
)
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const firstRequest = result.current.archiveTaskById("task-1");
const secondRequest = result.current.archiveTaskById("task-2");
expect(toastMock).toHaveBeenCalledTimes(1);
resolveFirst();
await firstRequest;
expect(dismissToastMock).not.toHaveBeenCalled();
resolveSecond();
await secondRequest;
expect(dismissToastMock).toHaveBeenCalledTimes(1);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
// @covers AC-TASKS-REMOVAL-NAVIGATION-004.2, AC-TASKS-REMOVAL-NAVIGATION-004.3
it("keeps the shared toast until a concurrent request resolves after another rejects", async () => {
let rejectFirst!: (error: Error) => void;
let resolveSecond!: () => void;
archiveTaskMock
.mockReturnValueOnce(
new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
);
const { result } = renderHook(() => useTaskActions());
const firstRequest = result.current.archiveTaskById("task-1");
const secondRequest = result.current.archiveTaskById("task-2");
rejectFirst(new Error(archiveFailureMessage));
await expect(firstRequest).rejects.toThrow(archiveFailureMessage);
expect(dismissToastMock).not.toHaveBeenCalled();
resolveSecond();
await secondRequest;
expect(dismissToastMock).toHaveBeenCalledTimes(1);
expect(dismissToastMock).toHaveBeenCalledWith(archiveProgressToastId);
});
it("shows localized retry guidance for a dirty-worktree delete conflict", async () => {
const { ApiError } = await import("@/lib/api/client");
deleteTaskMock.mockRejectedValueOnce(
+26 -5
View File
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { archiveTask, deleteTask, moveTask, updateTask } from "@/lib/api";
import type { DeleteTaskParams, WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
import { isTaskDeleteDirtyWorktreeError } from "@/lib/api/task-delete-errors";
@@ -20,8 +20,9 @@ export type TaskActionOptions = {
};
export function useTaskActions() {
const { toast } = useToast();
const { toast, dismissToast } = useToast();
const { t } = useTranslation();
const archiveProgressRef = useRef({ count: 0, toastId: null as string | null });
const moveTaskById = useCallback(async (taskId: string, payload: MovePayload) => {
return moveTask(taskId, payload);
@@ -45,9 +46,29 @@ export function useTaskActions() {
[t, toast],
);
const archiveTaskById = useCallback(async (taskId: string, opts?: TaskActionOptions) => {
return archiveTask(taskId, opts);
}, []);
const archiveTaskById = useCallback(
async (taskId: string, opts?: TaskActionOptions) => {
const progress = archiveProgressRef.current;
progress.count += 1;
if (progress.count === 1) {
progress.toastId = toast({
title: t("tasks:archivingInProgress"),
variant: "loading",
});
}
try {
return await archiveTask(taskId, opts);
} finally {
progress.count -= 1;
if (progress.count === 0 && progress.toastId) {
dismissToast(progress.toastId);
progress.toastId = null;
}
}
},
[dismissToast, t, toast],
);
const renameTaskById = useCallback(async (taskId: string, title: string) => {
return updateTask(taskId, { title });
+7 -5
View File
@@ -30,6 +30,9 @@ const tasks = ["A", "B"].map((id) => ({
workflowStepId: "step",
position: 0,
}));
const toastCallsWithVariant = (variant: string) =>
api.toast.mock.calls.filter(([input]) => input.variant === variant);
beforeEach(() => {
vi.clearAllMocks();
store = createAppStore();
@@ -112,7 +115,7 @@ describe("concurrent shared task menu removal", () => {
expect(await outcomeA).toBe(false);
});
expect(result.current.pendingTaskId).toBe("B");
expect(api.toast).toHaveBeenCalledTimes(1);
expect(toastCallsWithVariant("error")).toHaveLength(1);
request.mockResolvedValueOnce(undefined);
await act(async () => {
expect(await result.current[method]("B")).toBe(false);
@@ -149,7 +152,7 @@ describe("shared task menu removal", () => {
expect(operations).toHaveLength(1);
expect(operations[0]).toMatchObject({ taskIds: ["A"], departure: null });
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(api.toast).not.toHaveBeenCalled();
expect(toastCallsWithVariant("loading")).toHaveLength(Number(method === "runArchive"));
} finally {
await act(async () => {
release();
@@ -160,7 +163,6 @@ describe("shared task menu removal", () => {
expect(store.getState().kanban.tasks.map(({ id }) => id)).toEqual(["B"]);
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
expect(api.softNavigate).not.toHaveBeenCalled();
expect(api.toast).toHaveBeenCalledOnce();
expect(api.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
},
);
@@ -181,7 +183,7 @@ describe("shared task menu removal", () => {
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
expect(api.softNavigate).not.toHaveBeenCalled();
expect(api.toast).toHaveBeenCalledOnce();
expect(toastCallsWithVariant("error")).toHaveLength(1);
expect(api.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
},
);
@@ -227,7 +229,7 @@ describe("shared task menu removal", () => {
expect(await result.current[method]("A")).toBe(false);
});
expect(store.getState().kanban.tasks).toEqual(tasks);
expect(api.toast).toHaveBeenCalledTimes(1);
expect(toastCallsWithVariant("error")).toHaveLength(1);
expect(result.current.pendingTaskId).toBeNull();
request.mockResolvedValueOnce(undefined);
await act(async () => {
+1
View File
@@ -3,6 +3,7 @@
"archive": "Archive",
"archived": "Archived",
"archiveTask": "Archive task",
"archivingInProgress": "Archiving in progress",
"changesRequested": "Changes Requested",
"columnStep": "Step",
"columnTask": "Task",
+22
View File
@@ -386,6 +386,28 @@
"sshProbeHint": "「{{button}}」をクリックして、リモートにインストールされているエージェントを確認します。",
"sshProxyJumpHint": "単一の踏み台ホップのみ対応しています。連続したジャンプはサポートされていません。",
"sshProxyJumpLabel": "ProxyJump(任意)",
"sshReachabilityAgeLastProbe": "最終プローブ: {{age}}",
"sshReachabilityAgeLastSuccess": "最終成功: {{age}}",
"sshReachabilityConsecutiveFailures_one": "{{count}} 回連続で失敗",
"sshReachabilityConsecutiveFailures_other": "{{count}} 回連続で失敗",
"sshReachabilityDescription": "このホストに対する直近の SSH プローブ結果です。バックグラウンドで自動的に確認されます。",
"sshReachabilityHost": "プローブ対象ホスト: {{host}}",
"sshReachabilityLoadFailed": "到達性の状態を読み込めませんでした。状態は不明です。",
"sshReachabilityNeverProbed": "まだプローブされていません。",
"sshReachabilityNoSuccess": "成功したプローブの記録がありません。",
"sshReachabilityProbeNow": "今すぐプローブ",
"sshReachabilityProbingOff": "定期プローブは無効です。現在の状態ではなく、最後に記録された結果を表示しています。",
"sshReachabilityReasonAuth": "認証に失敗しました",
"sshReachabilityReasonConfig": "接続設定が無効です",
"sshReachabilityReasonHostKey": "ホストキーが固定されたフィンガープリントと一致しません",
"sshReachabilityReasonNetwork": "ネットワークエラーが発生しました",
"sshReachabilityReasonTimeout": "接続試行がタイムアウトしました",
"sshReachabilityReasonUnknown": "不明なエラーが発生しました",
"sshReachabilityStale": "古い結果: 過去 3 回の確認間隔内にプローブがありません",
"sshReachabilityStateReachable": "到達可能",
"sshReachabilityStateUnknown": "不明",
"sshReachabilityStateUnreachable": "到達不能",
"sshReachabilityTitle": "到達性",
"sshReclaimTaskDirDescription": "{{host}}で実行される各タスクは、終了後もチェックアウトを{{path}}に保持します。そのため、再開したタスクやアーカイブ解除したタスクは、作業を中断した場所から再開できます。",
"sshReclaimTaskDirLabel": "タスクがアーカイブまたは削除されたときに、そのタスクのディレクトリを削除する",
"sshReclaimTaskDirTitle": "リモートのタスクディレクトリ",
+10
View File
@@ -2280,6 +2280,16 @@
"launchErrorSessionPreview": "タスク launch に対応必要。",
"launchNeedsAttention": "launch に対応必要",
"launchErrorNoChanges": "task か branch 設定、どちらも変更なし。",
"launchWarningTitle": "SSH ホストに到達できない可能性があります",
"launchWarningHost": "プローブ対象ホスト: {{host}}",
"launchWarningLastSuccess": "最終成功: {{age}}",
"launchWarningNeverSucceeded": "このホストで成功したプローブは一度も記録されていません。",
"launchWarningReasonConfig": "前回の試行は、接続設定が無効なため失敗しました。",
"launchWarningReasonTimeout": "前回の試行はタイムアウトしました。",
"launchWarningReasonHostKey": "前回の試行は、ホストキーが固定されたフィンガープリントと一致しなかったため失敗しました。",
"launchWarningReasonAuth": "前回の試行は認証に失敗しました。",
"launchWarningReasonNetwork": "前回の試行はネットワークエラーで失敗しました。",
"launchWarningReasonUnknown": "前回の試行は不明な理由で失敗しました。",
"launchPromptPreview": "プロンプトプレビューを起動",
"retryWithDefaultBranch": "default branch で再試行",
"pickBaseBranch": "base branch を選ぶ",
+1
View File
@@ -3,6 +3,7 @@
"archive": "アーカイブ",
"archived": "アーカイブ済み",
"archiveTask": "タスクをアーカイブ",
"archivingInProgress": "アーカイブ中",
"changesRequested": "変更が要求されました",
"columnStep": "ステップ",
"columnTask": "タスク",
+1
View File
@@ -3,6 +3,7 @@
"archive": "Àŕćĥĩvē",
"archived": "Àŕćĥĩvēď",
"archiveTask": "Àŕćĥĩvē ţàśķ",
"archivingInProgress": "Àŕćĥĩvĩńĝ ĩń ƥŕōĝŕēśś",
"changesRequested": "Ćĥàńĝēś Ŕēqũēśţēď",
"columnStep": "Śţēƥ",
"columnTask": "Ţàśķ",
+1
View File
@@ -3,6 +3,7 @@
"archive": "Arquivar",
"archived": "Arquivada",
"archiveTask": "Arquivar tarefa",
"archivingInProgress": "A arquivar",
"changesRequested": "Alterações solicitadas",
"columnStep": "Passo",
"columnTask": "Tarefa",
+1
View File
@@ -3,6 +3,7 @@
"archive": "归档",
"archived": "已归档",
"archiveTask": "归档任务",
"archivingInProgress": "正在归档",
"changesRequested": "已请求更改",
"columnStep": "步骤",
"columnTask": "任务",
+1
View File
@@ -3,6 +3,7 @@
"archive": "歸檔",
"archived": "已歸檔",
"archiveTask": "歸檔任務",
"archivingInProgress": "正在歸檔",
"changesRequested": "已請求更改",
"columnStep": "步驟",
"columnTask": "任務",
+1
View File
@@ -3,6 +3,7 @@
"archive": "歸檔",
"archived": "已歸檔",
"archiveTask": "歸檔任務",
"archivingInProgress": "正在歸檔",
"changesRequested": "已請求更改",
"columnStep": "步驟",
"columnTask": "任務",
@@ -0,0 +1,164 @@
---
created: 2026-09-17
status: implemented
requirements:
- REQ-TASKS-REMOVAL-NAVIGATION-004
system_design:
- ../../specs/tasks/system-design/removal-navigation.md
legacy_specs: []
---
# Implementation Plan: Archive progress feedback
## Overview
Add one persistent loading toast around every user-initiated archive request,
then prove its desktop and phone presentation with the existing deferred
archive scenarios. The shared action hook changes first so all task surfaces
receive the behavior; the `/tasks` listing then joins that boundary, and the
same end-to-end flows capture review screenshots.
## Scope
### In scope
- Localized `Archiving in progress` feedback in the existing bottom-right toast
stack for single and bulk user archive actions.
- One progress toast per operation or bulk batch, retained until the last
archive request settles and dismissed on either success or failure.
- The `/tasks` listing and every archive surface already routed through
`useTaskActions`.
- Desktop and phone Playwright evidence plus PR-ready screenshots of the
controlled pending state.
### Out of scope
- Archive API, persistence, permissions, confirmation, cascade, navigation,
recovery, success, or failure semantics.
- Delete, unarchive, API, CLI, MCP, agent-driven, and other programmatic archive
operations.
- A new toast component, placement system, setting, feature flag, or mobile
navigation composition.
## Technical approach
### Shared archive action feedback
Add a private progress-toast lifecycle inside
`apps/web/hooks/use-task-actions.ts`. `archiveTaskById` creates the existing
non-expiring `loading` toast before invoking `archiveTask`, tracks concurrent
requests from the same hook instance, and dismisses it in `finally` only after
the final request settles. Keep outcome feedback with the current coordinator
and surface handlers.
Update `apps/web/app/tasks/tasks-page-client.tsx` to call
`archiveTaskById` instead of importing the transport function directly. Preserve
that listing's current refresh and terminal toast behavior.
Add `tasks:archivingInProgress` to English, pseudo, Portuguese, Simplified
Chinese, and generated Traditional Chinese catalogs. Do not place translated
copy at module scope.
### Rendered verification and PR evidence
Extend `apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts` while its
archive route is deliberately held. Assert the loading toast, spinner, polite
live region, bottom-right placement, and later removal before checking the
existing success and failure outcomes. Reuse the helper from the current
desktop and `mobile-chrome` specs.
Pass `prCapture` from both specs and capture one full-page pending-state image
per viewport. `CAPTURE_PR_ASSETS=1` writes both screenshots and their captions
to `apps/web/.pr-assets/manifest.json` for the PR workflow.
Nearest phone exemplar: the existing `ToastProvider` stack itself. It supplies
the fixed, status-bar-aware placement, live announcement, and non-blocking
presentation. This change adds a state to that shared surface, with no new
phone action, navigation, scroll owner, or touch target.
## ASCII UI preview
### UI-01: Desktop archive pending
Entry point: any desktop task archive action after confirmation or immediate
acceptance. State: the archive HTTP request is pending.
```text
+------------------------------------------------------------------+
| Kandev [user] |
| |
| Current task or destination remains usable |
| |
| +-------------------------------+ |
| | (spinner) Archiving in progress| |
| +-------------------------------+ |
+------------------------------------------------------------------+
fixed bottom-right toast stack
```
### UI-02: Phone archive pending
Entry point: any phone task archive action after confirmation or immediate
acceptance. State: the archive HTTP request is pending.
```text
+------------------------------+
| Task |
| |
| Current task or destination |
| remains usable |
| |
| +--------------------------+ |
| | (spinner) Archiving in | |
| | progress | |
| +--------------------------+ |
+------------------------------+
fixed above app status bar; viewport-contained
```
Structural requirements: the toast is non-blocking, localized, announced by
the existing polite live region, contains a visible loading indicator, and
does not time out while the request is pending. One operation or bulk batch
shows one toast. Spacing and line wrapping are illustrative and continue to use
the existing toast component. These previews map to
`AC-TASKS-REMOVAL-NAVIGATION-004.1` through `.3`.
## Tests
- `apps/web/hooks/use-task-actions.test.ts`: a deferred archive creates the
localized loading toast before settlement, does not dismiss it early,
dismisses on resolve and reject, and coalesces concurrent requests from one
hook instance.
- Existing terminal success and failure assertions remain unchanged, proving
the progress state adds no second outcome notification.
## E2E tests
- `apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts` covers
`AC-TASKS-REMOVAL-NAVIGATION-004.1` through `.3` on desktop and captures
`UI-01`.
- `apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts` covers the
same criteria in the `mobile-chrome` project, verifies viewport containment,
and captures `UI-02`.
## Work orders
- [x] [Task 01: Show archive progress feedback](task-01-show-archive-progress.md)
## Verification results
- Focused `useTaskActions` unit suite passed with resolve, reject, and
concurrent-request coverage.
- Web typecheck and the complete i18n catalog checks passed.
- Desktop Chromium and phone `mobile-chrome` archive scenarios passed and
produced reviewed, compressed PR screenshots.
- Specification, public-doc, and diff validation passed.
## Risks
- Bulk archives can accidentally create one toast per task unless the shared
action hook counts concurrent requests from the same operation.
- A missing `finally` dismissal can leave a permanent loading toast after a
rejection or navigation transition.
- The fixed-width desktop toast must stay inside the configured phone viewport;
rendered geometry and horizontal-overflow checks guard that contract.
@@ -0,0 +1,149 @@
---
id: "01-show-archive-progress"
title: "Show archive progress feedback"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-REMOVAL-NAVIGATION-004
acceptance_criteria:
- AC-TASKS-REMOVAL-NAVIGATION-004.1
- AC-TASKS-REMOVAL-NAVIGATION-004.2
- AC-TASKS-REMOVAL-NAVIGATION-004.3
system_design:
- ../../specs/tasks/system-design/removal-navigation.md
---
# Task 01: Show archive progress feedback
## Summary
Wrap user-initiated archive requests in one localized, non-expiring loading
toast and route the remaining `/tasks` archive action through the shared hook.
Prove the pending, success, failure, desktop, and phone states, then capture the
two pending views for the pull request.
## In scope
- Shared archive progress-toast lifecycle and concurrent-request coalescing.
- `/tasks` listing adoption of the shared archive action.
- Shipped locale catalogs.
- Unit and focused desktop/mobile Playwright coverage.
- PR asset capture for both viewports.
## Out of scope
- Changes to archive transport, confirmation, cascade, navigation, recovery,
persistence, or permissions.
- Loading feedback for delete, unarchive, or programmatic archive callers.
- New toast or mobile navigation primitives.
## Acceptance
- A single or bulk archive shows one localized loading toast before its request
settles; concurrent tasks in the same bulk action do not create a toast pile.
- The toast remains present without a timeout while pending, then is dismissed
on resolve or reject while existing terminal feedback remains unchanged.
- Desktop and phone tests prove the visible spinner, polite announcement,
bottom-right viewport placement, cancellation behavior, cleanup, and PR
screenshots.
## ASCII UI preview
### UI-01: Desktop archive pending
Full preview: [plan.md#ui-01-desktop-archive-pending](plan.md#ui-01-desktop-archive-pending).
Applies to `AC-TASKS-REMOVAL-NAVIGATION-004.1` through `.3`.
```text
+-------------------------------+
| (spinner) Archiving in progress|
+-------------------------------+
fixed bottom-right; non-blocking
```
### UI-02: Phone archive pending
Full preview: [plan.md#ui-02-phone-archive-pending](plan.md#ui-02-phone-archive-pending).
Applies to `AC-TASKS-REMOVAL-NAVIGATION-004.1` through `.3`.
```text
+------------------------------+
| +--------------------------+ |
| | (spinner) Archiving in | |
| | progress | |
| +--------------------------+ |
+------------------------------+
viewport-contained above status bar
```
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run hooks/use-task-actions.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && CAPTURE_PR_ASSETS=1 pnpm e2e:run tests/task/sidebar-immediate-archive.spec.ts)
(cd apps/web && CAPTURE_PR_ASSETS=1 pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-immediate-archive.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
git diff --check -- docs/public docs/specs docs/plans apps/web
```
## Files likely touched
- `apps/web/hooks/use-task-actions.ts`
- `apps/web/hooks/use-task-actions.test.ts`
- `apps/web/app/tasks/tasks-page-client.tsx`
- `apps/web/src/locales/en/tasks.json`
- `apps/web/src/locales/pseudo/tasks.json`
- `apps/web/src/locales/pt-pt/tasks.json`
- `apps/web/src/locales/zh-cn/tasks.json`
- `apps/web/src/locales/zh-hk/tasks.json`
- `apps/web/src/locales/zh-tw/tasks.json`
- `apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts`
- `apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts`
- `apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts`
- `docs/public/tasks-and-workflows.md`
## Dependencies
None. The existing task-removal coordinator, toast loading variant, deferred
archive test helper, and PR asset fixture are the foundations.
## Risks
- Hook instances are surface-local, so the counter must coalesce only requests
started by one operation without suppressing a separate concurrent action.
- Toast cleanup must run for both fulfilled and rejected archive promises.
- Phone capture must occur before reopening the task picker so the screenshot
shows the global toast in the normal task surface.
## Parallelism
`sequential`
## Inputs
- `REQ-TASKS-REMOVAL-NAVIGATION-004` and the archive progress section of the
task-removal system design.
- Existing `useTaskActions`, `ToastProvider`, archive-removal tests, and
`PrAssetCapture` patterns.
- The existing `ToastProvider` phone stack is the nearest mobile exemplar; its
status-bar-aware fixed placement and polite live region remain unchanged.
## Results
- Added a reference-counted loading-toast lifecycle to the shared archive
action and routed the tasks listing through it.
- Added complete locale coverage and documented the visible pending state in
the public task lifecycle guide.
- Added unit and desktop/phone Playwright coverage for cancellation, pending,
success, failure, accessibility, placement, and concurrent archives.
- Captured and reviewed fresh desktop and phone screenshots for the pull
request.
+6 -1
View File
@@ -940,7 +940,12 @@ On a phone, archive uses a focused confirmation step in the open Tasks sheet,
or a compact bottom sheet from a page. [Phone confirmation controls](mobile-remote-access.md#confirm-an-action-on-a-phone)
explain how to review the action and return to your list without losing your place.
After you confirm archive, the task stays in the active sidebar and phone task picker in a dimmed, busy state with a spinner while the request is pending. It disappears after a successful archive. If the request fails, the task returns to its normal state. Saved views that include archived tasks still show confirmed archives.
After you confirm archive, the task stays in the active sidebar and phone task
picker in a dimmed, busy state with a spinner while the request is pending. A
bottom-right **Archiving in progress** toast remains visible until the request
settles. The task disappears after a successful archive. If the request fails,
the task returns to its normal state. Saved views that include archived tasks
still show confirmed archives.
Archive records the task as archived and removes it from active views immediately. Runtime stopping and physical cleanup then run in the background with a 60-second timeout. Cleanup is best-effort: a stop or deletion failure is logged and does not undo the archive, and Kandev preserves a runtime or environment when a nonterminal session cannot be stopped. Shared inherited environments and borrowed worktrees are also preserved while another active task still uses them.
@@ -2,6 +2,7 @@
status: active
system: tasks
created: 2026-09-10
updated: 2026-09-17
owners:
- kandev
---
@@ -103,6 +104,28 @@ active task navigation after the archive operation completes.
show confirmed archived tasks according to their filters; pending archive
intent shall not manufacture a confirmed archived task.
### REQ-TASKS-REMOVAL-NAVIGATION-004: Archive progress feedback
**Intent:** Give users persistent, truthful feedback when a user-initiated
archive request takes time to finish.
#### Acceptance criteria
- **AC-TASKS-REMOVAL-NAVIGATION-004.1:** When a user accepts an archive action
from any desktop or phone task surface, the system shall show one localized
loading toast for that operation or bulk batch in the existing bottom-right
toast stack. The English source copy shall read `Archiving in progress`, the
toast shall include a loading indicator, and the existing polite live region
shall announce it.
- **AC-TASKS-REMOVAL-NAVIGATION-004.2:** The archive progress toast shall not
auto-dismiss while any archive request in its operation or batch remains
pending. After every request settles, the progress toast shall disappear and
the existing success or failure feedback shall remain the only terminal
notification.
- **AC-TASKS-REMOVAL-NAVIGATION-004.3:** Cancelling or dismissing archive
confirmation shall show no progress toast and issue no request. Programmatic,
API, CLI, MCP, and agent-driven archive operations shall remain unchanged.
## Compatibility and exclusions
Existing archive confirmation preferences and cascade choices remain governed
@@ -123,3 +146,4 @@ Undo, new settings, and a new mobile navigation composition are excluded.
- [Task removal navigation](../../../plans/task-removal-navigation/plan.md)
- [Immediate sidebar archive](../../../plans/immediate-sidebar-archive/plan.md)
- [Archive progress feedback](../../../plans/archive-progress-feedback/plan.md)
@@ -2,10 +2,12 @@
status: current
system: tasks
created: 2026-09-10
updated: 2026-09-17
requirements:
- REQ-TASKS-REMOVAL-NAVIGATION-001
- REQ-TASKS-REMOVAL-NAVIGATION-002
- REQ-TASKS-REMOVAL-NAVIGATION-003
- REQ-TASKS-REMOVAL-NAVIGATION-004
owners:
- kandev
---
@@ -40,6 +42,7 @@ controlled delayed-response regressions before production changes.
| REQ-TASKS-REMOVAL-NAVIGATION-001 | Presentation boundary, navigation, entry points, mobile |
| REQ-TASKS-REMOVAL-NAVIGATION-002 | Operation state, reconciliation, failure recovery, tests |
| REQ-TASKS-REMOVAL-NAVIGATION-003 | Pending archive presentation in shared task navigation |
| REQ-TASKS-REMOVAL-NAVIGATION-004 | Archive progress feedback and notification lifecycle |
## Immediate sidebar archive projection
@@ -89,6 +92,38 @@ task navigation. Reopening the phone picker while a request is pending must
still show the dimmed spinner row. No new copy, geometry, or navigation
composition is required.
## Archive progress feedback
`useTaskActions.archiveTaskById` is the common user-action mutation boundary
for the board, preview, sidebar, task switcher, task detail, command, message,
and bulk archive paths. Keep the low-level `archiveTask` API function
transport-only so API helpers, tests, and non-UI callers do not create browser
feedback. The `/tasks` listing is the only current user-facing caller that
invokes `archiveTask` directly; route that action through `useTaskActions` so it
shares the same progress lifecycle without changing its existing refresh,
success, or failure handling.
The archive action hook owns one loading-toast record with an in-flight request
count. The first archive request creates a localized `loading` toast through
`ToastProvider`; concurrent requests started by the same bulk operation reuse
that toast and increment the count. Every request decrements the count in a
`finally` path, and the last settlement dismisses the toast. Independent
operations started from separate surfaces retain separate feedback, matching
the existing surface-local concurrency contract.
The loading toast uses the existing non-expiring loading variant, spinner, and
bottom-right `aria-live="polite"` toast stack. It has the title `Archiving in
progress` in the English catalog and equivalent copy in every shipped locale.
It adds no action, drawer, scroll owner, or focus target. Desktop and phone use
the same toast component; the phone check verifies that the fixed toast remains
inside the viewport and above the existing app status bar.
The progress lifetime is scoped to the archive mutation request. On settlement
the action hook dismisses it without claiming an outcome. The existing removal
coordinator and listing handlers remain the sole owners of success, failure,
partial-failure, navigation, and recovery notifications, which prevents a
second terminal toast or a second error report.
## Operation state
Add a small browser-local removal state within the existing application store,
@@ -204,6 +239,11 @@ Bulk results retain failed IDs for retry, reconcile successful IDs, and report
one batch result. Conflicts reuse the existing discard guidance. Avoid duplicate
toasts between the coordinator, `useTaskActions`, and bulk callers.
Every archive request dismisses its progress contribution in `finally`, so a
rejection, component transition, or failed recovery cannot strand a loading
toast. Cancellation never reaches the archive action hook and therefore creates
no progress notification.
## Entry points and mobile composition
Integrate desktop sidebar and phone switcher actions, `useTaskCRUD` board actions,
@@ -241,6 +281,12 @@ Keep controls for cold archived views, unselected removal, and remote removal.
Use real isolated backend fixtures, desktop and mobile projects, and managed
production builds. Exact scenario mapping and commands belong to the plan.
The existing deferred desktop-sidebar and phone-picker archive scenarios also
assert the progress toast before releasing the request, its polite live region,
loading indicator, bottom-right viewport placement, and its removal on failure
and success. With PR asset capture enabled, those same deterministic pending
states produce the desktop and phone screenshots used in the pull request.
## Related records
- [Archive confirmation](archive-confirmation.md)
@@ -252,3 +298,4 @@ This local orchestration change needs no new ADR: its constraints and rationale
are fully captured by this design and its requirements.
- [Immediate sidebar archive plan](../../../plans/immediate-sidebar-archive/plan.md)
- [Archive progress feedback plan](../../../plans/archive-progress-feedback/plan.md)