feat: show progress while archiving tasks (#3775)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "Archive",
|
||||
"archived": "Archived",
|
||||
"archiveTask": "Archive task",
|
||||
"archivingInProgress": "Archiving in progress",
|
||||
"changesRequested": "Changes Requested",
|
||||
"columnStep": "Step",
|
||||
"columnTask": "Task",
|
||||
|
||||
@@ -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": "リモートのタスクディレクトリ",
|
||||
|
||||
@@ -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 を選ぶ",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "アーカイブ",
|
||||
"archived": "アーカイブ済み",
|
||||
"archiveTask": "タスクをアーカイブ",
|
||||
"archivingInProgress": "アーカイブ中",
|
||||
"changesRequested": "変更が要求されました",
|
||||
"columnStep": "ステップ",
|
||||
"columnTask": "タスク",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "Àŕćĥĩvē",
|
||||
"archived": "Àŕćĥĩvēď",
|
||||
"archiveTask": "Àŕćĥĩvē ţàśķ",
|
||||
"archivingInProgress": "Àŕćĥĩvĩńĝ ĩń ƥŕōĝŕēśś",
|
||||
"changesRequested": "Ćĥàńĝēś Ŕēqũēśţēď",
|
||||
"columnStep": "Śţēƥ",
|
||||
"columnTask": "Ţàśķ",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "Arquivar",
|
||||
"archived": "Arquivada",
|
||||
"archiveTask": "Arquivar tarefa",
|
||||
"archivingInProgress": "A arquivar",
|
||||
"changesRequested": "Alterações solicitadas",
|
||||
"columnStep": "Passo",
|
||||
"columnTask": "Tarefa",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "归档",
|
||||
"archived": "已归档",
|
||||
"archiveTask": "归档任务",
|
||||
"archivingInProgress": "正在归档",
|
||||
"changesRequested": "已请求更改",
|
||||
"columnStep": "步骤",
|
||||
"columnTask": "任务",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"archive": "歸檔",
|
||||
"archived": "已歸檔",
|
||||
"archiveTask": "歸檔任務",
|
||||
"archivingInProgress": "正在歸檔",
|
||||
"changesRequested": "已請求更改",
|
||||
"columnStep": "步驟",
|
||||
"columnTask": "任務",
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user