diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index b503845158..0cc2e61499 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -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(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( diff --git a/apps/web/app/threads/threads-page-archive.test.tsx b/apps/web/app/threads/threads-page-archive.test.tsx index 021df8a7c4..be7b6f19f5 100644 --- a/apps/web/app/threads/threads-page-archive.test.tsx +++ b/apps/web/app/threads/threads-page-archive.test.tsx @@ -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(); diff --git a/apps/web/components/toast-provider.tsx b/apps/web/components/toast-provider.tsx index 034cbac52d..76e499c6e4 100644 --- a/apps/web/components/toast-provider.tsx +++ b/apps/web/components/toast-provider.tsx @@ -150,7 +150,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { function ToastList({ toasts }: { toasts: Toast[] }) { return (
{ + 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, }); }); diff --git a/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts index af6b776291..6926a9d88c 100644 --- a/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts +++ b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts @@ -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) => { @@ -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(); diff --git a/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts b/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts index 2bc7974cba..2b2f93d478 100644 --- a/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts @@ -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, }); }); diff --git a/apps/web/hooks/use-task-actions.test.ts b/apps/web/hooks/use-task-actions.test.ts index 0fee6859fa..c02ae4b0ba 100644 --- a/apps/web/hooks/use-task-actions.test.ts +++ b/apps/web/hooks/use-task-actions.test.ts @@ -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((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((resolve) => { + resolveFirst = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((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((_resolve, reject) => { + rejectFirst = reject; + }), + ) + .mockReturnValueOnce( + new Promise((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( diff --git a/apps/web/hooks/use-task-actions.ts b/apps/web/hooks/use-task-actions.ts index 3be3a45041..153838f847 100644 --- a/apps/web/hooks/use-task-actions.ts +++ b/apps/web/hooks/use-task-actions.ts @@ -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 }); diff --git a/apps/web/hooks/use-task-menu-actions.test.ts b/apps/web/hooks/use-task-menu-actions.test.ts index 5052455890..edf6a1e1a8 100644 --- a/apps/web/hooks/use-task-menu-actions.test.ts +++ b/apps/web/hooks/use-task-menu-actions.test.ts @@ -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 () => { diff --git a/apps/web/src/locales/en/tasks.json b/apps/web/src/locales/en/tasks.json index 48540410f5..586ba494fa 100644 --- a/apps/web/src/locales/en/tasks.json +++ b/apps/web/src/locales/en/tasks.json @@ -3,6 +3,7 @@ "archive": "Archive", "archived": "Archived", "archiveTask": "Archive task", + "archivingInProgress": "Archiving in progress", "changesRequested": "Changes Requested", "columnStep": "Step", "columnTask": "Task", diff --git a/apps/web/src/locales/ja/executors.json b/apps/web/src/locales/ja/executors.json index b4c2dfa1f1..584194ba49 100644 --- a/apps/web/src/locales/ja/executors.json +++ b/apps/web/src/locales/ja/executors.json @@ -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": "リモートのタスクディレクトリ", diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index 18f30b05e2..684cf35d0f 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -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 を選ぶ", diff --git a/apps/web/src/locales/ja/tasks.json b/apps/web/src/locales/ja/tasks.json index 49ffbe3cdc..e81688daa5 100644 --- a/apps/web/src/locales/ja/tasks.json +++ b/apps/web/src/locales/ja/tasks.json @@ -3,6 +3,7 @@ "archive": "アーカイブ", "archived": "アーカイブ済み", "archiveTask": "タスクをアーカイブ", + "archivingInProgress": "アーカイブ中", "changesRequested": "変更が要求されました", "columnStep": "ステップ", "columnTask": "タスク", diff --git a/apps/web/src/locales/pseudo/tasks.json b/apps/web/src/locales/pseudo/tasks.json index 6171dbc8a0..e4bbcda6e2 100644 --- a/apps/web/src/locales/pseudo/tasks.json +++ b/apps/web/src/locales/pseudo/tasks.json @@ -3,6 +3,7 @@ "archive": "Àŕćĥĩvē", "archived": "Àŕćĥĩvēď", "archiveTask": "Àŕćĥĩvē ţàśķ", + "archivingInProgress": "Àŕćĥĩvĩńĝ ĩń ƥŕōĝŕēśś", "changesRequested": "Ćĥàńĝēś Ŕēqũēśţēď", "columnStep": "Śţēƥ", "columnTask": "Ţàśķ", diff --git a/apps/web/src/locales/pt-pt/tasks.json b/apps/web/src/locales/pt-pt/tasks.json index 6d386e1079..9a60810a16 100644 --- a/apps/web/src/locales/pt-pt/tasks.json +++ b/apps/web/src/locales/pt-pt/tasks.json @@ -3,6 +3,7 @@ "archive": "Arquivar", "archived": "Arquivada", "archiveTask": "Arquivar tarefa", + "archivingInProgress": "A arquivar", "changesRequested": "Alterações solicitadas", "columnStep": "Passo", "columnTask": "Tarefa", diff --git a/apps/web/src/locales/zh-cn/tasks.json b/apps/web/src/locales/zh-cn/tasks.json index 68a05f31af..575a202be5 100644 --- a/apps/web/src/locales/zh-cn/tasks.json +++ b/apps/web/src/locales/zh-cn/tasks.json @@ -3,6 +3,7 @@ "archive": "归档", "archived": "已归档", "archiveTask": "归档任务", + "archivingInProgress": "正在归档", "changesRequested": "已请求更改", "columnStep": "步骤", "columnTask": "任务", diff --git a/apps/web/src/locales/zh-hk/tasks.json b/apps/web/src/locales/zh-hk/tasks.json index a264d2ed33..bc4f45ba75 100644 --- a/apps/web/src/locales/zh-hk/tasks.json +++ b/apps/web/src/locales/zh-hk/tasks.json @@ -3,6 +3,7 @@ "archive": "歸檔", "archived": "已歸檔", "archiveTask": "歸檔任務", + "archivingInProgress": "正在歸檔", "changesRequested": "已請求更改", "columnStep": "步驟", "columnTask": "任務", diff --git a/apps/web/src/locales/zh-tw/tasks.json b/apps/web/src/locales/zh-tw/tasks.json index 2f4e57552b..eb1d68f6be 100644 --- a/apps/web/src/locales/zh-tw/tasks.json +++ b/apps/web/src/locales/zh-tw/tasks.json @@ -3,6 +3,7 @@ "archive": "歸檔", "archived": "已歸檔", "archiveTask": "歸檔任務", + "archivingInProgress": "正在歸檔", "changesRequested": "已請求更改", "columnStep": "步驟", "columnTask": "任務", diff --git a/docs/plans/archive-progress-feedback/plan.md b/docs/plans/archive-progress-feedback/plan.md new file mode 100644 index 0000000000..e5d483b4c6 --- /dev/null +++ b/docs/plans/archive-progress-feedback/plan.md @@ -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. diff --git a/docs/plans/archive-progress-feedback/task-01-show-archive-progress.md b/docs/plans/archive-progress-feedback/task-01-show-archive-progress.md new file mode 100644 index 0000000000..691c248b41 --- /dev/null +++ b/docs/plans/archive-progress-feedback/task-01-show-archive-progress.md @@ -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. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 735cfb978c..26694bb3e2 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -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. diff --git a/docs/specs/tasks/requirements/removal-navigation.md b/docs/specs/tasks/requirements/removal-navigation.md index eeec15b35d..4c8f1ed6e2 100644 --- a/docs/specs/tasks/requirements/removal-navigation.md +++ b/docs/specs/tasks/requirements/removal-navigation.md @@ -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) diff --git a/docs/specs/tasks/system-design/removal-navigation.md b/docs/specs/tasks/system-design/removal-navigation.md index b4d58b1dad..790f7bb2b8 100644 --- a/docs/specs/tasks/system-design/removal-navigation.md +++ b/docs/specs/tasks/system-design/removal-navigation.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)