feat(web): archive the current task from the command palette (#2346)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-07 11:29:24 +02:00
committed by GitHub
parent 8b2e162e9d
commit ac520a8cdc
12 changed files with 558 additions and 122 deletions
+1 -13
View File
@@ -9,6 +9,7 @@ import { IconSun, IconMoon, IconMessageCircle, IconSparkles } from "@tabler/icon
import { useStaticDestinations } from "@/hooks/use-app-destinations";
import { PALETTE_NAVIGATION_GROUP_KEY } from "@/lib/navigation/surface-policy";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { searchKeywords } from "@/lib/commands/search-keywords";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { useAppShortcuts } from "@/hooks/use-app-shortcuts";
import { usePluginShortcuts } from "@/hooks/use-plugin-shortcuts";
@@ -25,19 +26,6 @@ type PushFn = ReturnType<typeof useRouter>["push"];
const GROUP_NAVIGATION = PALETTE_NAVIGATION_GROUP_KEY;
const GROUP_ACTIONS = "common:commandGroupActions";
/**
* Search keywords are stored as one comma-separated catalog value so a
* translator can localize the whole set in one entry. They are matched, never
* displayed; the palette itself selects commands by `id` (see
* `command-panel-footer.tsx`), so no behavior keys off this copy.
*/
function searchKeywords(t: TFunction, key: string): string[] {
return t(key)
.split(",")
.map((keyword) => keyword.trim())
.filter(Boolean);
}
/**
* The Navigation group comes from the navigation manifest
* (`lib/navigation/core-destinations.ts`), so a destination cannot reach the palette
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import { t as translate } from "@/lib/i18n";
import { buildTaskCommands } from "./session-commands";
const t = translate as unknown as TFunction;
const ARCHIVE_COMMAND_ID = "task-archive";
function build(overrides: Partial<Parameters<typeof buildTaskCommands>[0]> = {}) {
return buildTaskCommands({
activeTaskId: "task-A",
isTaskArchived: false,
t,
openNewAgent: vi.fn(),
openSubtask: vi.fn(),
requestArchive: vi.fn(),
...overrides,
});
}
describe("buildTaskCommands", () => {
it("offers an archive command that matches typing 'archive'", () => {
const archive = build().find((cmd) => cmd.id === ARCHIVE_COMMAND_ID);
expect(archive).toBeDefined();
expect(archive?.label).toBe("Archive task");
expect(archive?.group).toBe("Tasks");
expect(archive?.keywords).toContain("archive");
});
it("groups the archive command with the other task commands", () => {
const commands = build();
const subtask = commands.find((cmd) => cmd.id === "subtask-create");
const archive = commands.find((cmd) => cmd.id === ARCHIVE_COMMAND_ID);
expect(archive?.group).toBe(subtask?.group);
});
it("runs the archive request rather than archiving directly", () => {
const requestArchive = vi.fn();
build({ requestArchive })
.find((cmd) => cmd.id === ARCHIVE_COMMAND_ID)
?.action?.();
expect(requestArchive).toHaveBeenCalledTimes(1);
});
it("hides the archive command for an archived task", () => {
const commands = build({ isTaskArchived: true });
expect(commands.find((cmd) => cmd.id === ARCHIVE_COMMAND_ID)).toBeUndefined();
expect(commands.find((cmd) => cmd.id === "subtask-create")).toBeDefined();
});
it("registers no task commands without an active task", () => {
expect(build({ activeTaskId: null })).toEqual([]);
});
});
+137 -47
View File
@@ -1,7 +1,10 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
IconArchive,
IconPlayerStop,
IconGitCommit,
IconArrowUp,
@@ -28,6 +31,9 @@ import { createFile } from "@/lib/ws/workspace-files";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { NewSessionDialog } from "@/components/task/new-session-dialog";
import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog";
import { TaskArchiveConfirmFlow } from "@/components/task/task-archive-confirm-flow";
import { useTaskArchiveConfirm } from "@/hooks/use-task-archive-confirm";
import { searchKeywords } from "@/lib/commands/search-keywords";
import type { CommandItem } from "@/lib/commands/types";
type SessionCommandsProps = {
@@ -36,6 +42,8 @@ type SessionCommandsProps = {
isAgentRunning?: boolean;
hasWorktree?: boolean;
isPassthrough?: boolean;
/** Archived tasks are read-only, so the palette hides the archive command. */
isTaskArchived?: boolean;
};
type GitRunFn = (
@@ -154,13 +162,25 @@ function buildWorkspaceCommands(sessionId: string): CommandItem[] {
];
}
function buildTaskCommands(
activeTaskId: string | null,
openNewAgent: () => void,
openSubtask: () => void,
): CommandItem[] {
type TaskCommandOptions = {
activeTaskId: string | null;
isTaskArchived: boolean;
t: TFunction;
openNewAgent: () => void;
openSubtask: () => void;
requestArchive: () => void;
};
export function buildTaskCommands({
activeTaskId,
isTaskArchived,
t,
openNewAgent,
openSubtask,
requestArchive,
}: TaskCommandOptions): CommandItem[] {
if (!activeTaskId) return [];
return [
const items: CommandItem[] = [
{
id: "agent-new",
label: "New Agent",
@@ -172,12 +192,25 @@ function buildTaskCommands(
{
id: "subtask-create",
label: "Create Subtask",
group: "Tasks",
group: t("common:commandGroupTasks"),
icon: <IconSubtask className="size-3.5" />,
keywords: ["subtask", "create", "new subtask", "new sub-task", "child task"],
action: openSubtask,
},
];
// An archived task cannot be archived again; the palette offers no archive
// entry for it rather than surfacing a command that always fails.
if (!isTaskArchived) {
items.push({
id: "task-archive",
label: t("tasks:archiveTask"),
group: t("common:commandGroupTasks"),
icon: <IconArchive className="size-3.5" />,
keywords: searchKeywords(t, "common:commandArchiveTaskKeywords"),
action: requestArchive,
});
}
return items;
}
function buildPanelCommands(
@@ -230,13 +263,72 @@ function buildPanelCommands(
return items;
}
function useCancelTurn(sessionId: string | null) {
return useCallback(async () => {
if (!sessionId) return;
const client = getWebSocketClient();
if (!client) return;
try {
await client.request("agent.cancel", { session_id: sessionId }, 15000);
} catch (error) {
console.error("Failed to cancel agent turn:", error);
}
}, [sessionId]);
}
function useCommandDialogState() {
const [newAgentOpen, setNewAgentOpen] = useState(false);
const [subtaskOpen, setSubtaskOpen] = useState(false);
const openNewAgent = useCallback(() => setNewAgentOpen(true), []);
const openSubtask = useCallback(() => setSubtaskOpen(true), []);
return { newAgentOpen, setNewAgentOpen, subtaskOpen, setSubtaskOpen, openNewAgent, openSubtask };
}
type SessionCommandDialogsProps = {
activeTaskId: string;
activeTaskTitle: string;
dialogs: ReturnType<typeof useCommandDialogState>;
archive: ReturnType<typeof useTaskArchiveConfirm>;
};
/** Dialogs the task-scoped palette commands open. */
function SessionCommandDialogs({
activeTaskId,
activeTaskTitle,
dialogs,
archive,
}: SessionCommandDialogsProps) {
return (
<>
<NewSessionDialog
open={dialogs.newAgentOpen}
onOpenChange={dialogs.setNewAgentOpen}
taskId={activeTaskId}
/>
<NewSubtaskDialog
open={dialogs.subtaskOpen}
onOpenChange={dialogs.setSubtaskOpen}
parentTaskId={activeTaskId}
parentTaskTitle={activeTaskTitle}
/>
<TaskArchiveConfirmFlow
taskId={activeTaskId}
archive={archive}
confirmTestId="palette-archive-confirm"
/>
</>
);
}
export function SessionCommands({
sessionId,
baseBranch,
isAgentRunning,
hasWorktree,
isPassthrough,
isTaskArchived,
}: SessionCommandsProps) {
const { t } = useTranslation();
const git = useGitOperations(sessionId);
const panels = usePanelActions();
const { openCommitDialog, openPRDialog } = useVcsDialogs();
@@ -249,21 +341,9 @@ export function SessionCommands({
return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? "";
});
const [showNewAgentDialog, setShowNewAgentDialog] = useState(false);
const [showSubtaskDialog, setShowSubtaskDialog] = useState(false);
const openNewAgent = useCallback(() => setShowNewAgentDialog(true), []);
const openSubtask = useCallback(() => setShowSubtaskDialog(true), []);
const cancelTurn = useCallback(async () => {
if (!sessionId) return;
const client = getWebSocketClient();
if (!client) return;
try {
await client.request("agent.cancel", { session_id: sessionId }, 15000);
} catch (error) {
console.error("Failed to cancel agent turn:", error);
}
}, [sessionId]);
const dialogs = useCommandDialogState();
const { openNewAgent, openSubtask } = dialogs;
const cancelTurn = useCancelTurn(sessionId);
const runGitWithFeedback = useCallback(
async (
@@ -276,16 +356,32 @@ export function SessionCommands({
[panels, gitWithFeedback],
);
const archive = useTaskArchiveConfirm(activeTaskId);
const { requestArchive } = archive;
// Session-scoped commands need a live session; task-scoped ones only need the
// task, so they stay available while a session is still being ensured.
const commands = useMemo<CommandItem[]>(() => {
if (!sessionId) return [];
const sessionScoped = sessionId
? [
...buildSessionCommands(isAgentRunning, cancelTurn),
...(hasWorktree
? buildGitCommands(git, baseBranch, openCommitDialog, openPRDialog, runGitWithFeedback)
: []),
...(hasWorktree ? buildWorkspaceCommands(sessionId) : []),
...buildPanelCommands(panels, isPassthrough),
]
: [];
const items = [
...buildSessionCommands(isAgentRunning, cancelTurn),
...(hasWorktree
? buildGitCommands(git, baseBranch, openCommitDialog, openPRDialog, runGitWithFeedback)
: []),
...(hasWorktree ? buildWorkspaceCommands(sessionId) : []),
...buildPanelCommands(panels, isPassthrough),
...buildTaskCommands(activeTaskId, openNewAgent, openSubtask),
...sessionScoped,
...buildTaskCommands({
activeTaskId,
isTaskArchived: Boolean(isTaskArchived),
t,
openNewAgent,
openSubtask,
requestArchive,
}),
];
return items.map((cmd) => ({ ...cmd, priority: 0 }));
}, [
@@ -298,32 +394,26 @@ export function SessionCommands({
isAgentRunning,
hasWorktree,
isPassthrough,
isTaskArchived,
t,
openCommitDialog,
openPRDialog,
runGitWithFeedback,
openNewAgent,
openSubtask,
requestArchive,
]);
useRegisterCommands(commands);
if (!activeTaskId) return null;
return (
<>
{activeTaskId && (
<NewSessionDialog
open={showNewAgentDialog}
onOpenChange={setShowNewAgentDialog}
taskId={activeTaskId}
/>
)}
{activeTaskId && (
<NewSubtaskDialog
open={showSubtaskDialog}
onOpenChange={setShowSubtaskDialog}
parentTaskId={activeTaskId}
parentTaskTitle={activeTaskTitle}
/>
)}
</>
<SessionCommandDialogs
activeTaskId={activeTaskId}
activeTaskTitle={activeTaskTitle}
dialogs={dialogs}
archive={archive}
/>
);
}
@@ -2,11 +2,9 @@
import { useCallback, useState, type ReactNode } from "react";
import { IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useToast } from "@/components/toast-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import { TaskArchiveConfirmFlow } from "@/components/task/task-archive-confirm-flow";
import { useAppStore } from "@/components/state-provider";
import { useTaskArchiveConfirm } from "@/hooks/use-task-archive-confirm";
import {
markPRClosedBannerDismissed,
markPRMergedBannerDismissed,
@@ -15,47 +13,6 @@ import {
} from "@/lib/local-storage";
import { useTranslation } from "react-i18next";
type ArchiveTarget = { title: string; executorType?: string | null };
// Archiving from the terminal-state banners goes through the same confirmation
// dialog as every other archive surface. Only failures toast; on success the
// archive-and-switch flow moves the user to the next task.
function useBannerArchiveConfirm(taskId: string) {
const { t } = useTranslation();
const store = useAppStoreApi();
const archiveAndSwitch = useArchiveAndSwitchTask();
const { toast } = useToast();
const [target, setTarget] = useState<ArchiveTarget | null>(null);
const [isPending, setIsPending] = useState(false);
const requestArchive = useCallback(() => {
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
setTarget({
title: task?.title ?? t("task:thisTask"),
executorType: task?.primaryExecutorType,
});
}, [store, taskId]);
const closeConfirm = useCallback(() => setTarget(null), []);
const confirmArchive = useCallback(
async ({ cascade }: { cascade: boolean }) => {
setIsPending(true);
try {
await archiveAndSwitch(taskId, { cascade });
} catch {
toast({ description: t("task:failedToArchiveTask"), variant: "error" });
} finally {
setIsPending(false);
}
},
[archiveAndSwitch, taskId, toast],
);
return { target, requestArchive, closeConfirm, confirmArchive, isPending };
}
// Presentational banner shared by PRMergedBanner / PRClosedBanner — an icon, a
// message, and Archive + Dismiss controls. Colors/icon/testIds are supplied by
// the caller so the two variants stay visually distinct. The Archive control
@@ -80,8 +37,7 @@ function ArchiveDismissBanner({
onDismiss: () => void;
}) {
const { t } = useTranslation();
const { target, requestArchive, closeConfirm, confirmArchive, isPending } =
useBannerArchiveConfirm(taskId);
const archive = useTaskArchiveConfirm(taskId);
return (
<>
<div data-testid={`${testIdPrefix}-banner`} className={containerClass}>
@@ -90,7 +46,7 @@ function ArchiveDismissBanner({
<button
type="button"
data-testid={`${testIdPrefix}-archive-button`}
onClick={requestArchive}
onClick={archive.requestArchive}
className={archiveClass}
>
{t("task:archive")}
@@ -105,16 +61,9 @@ function ArchiveDismissBanner({
<IconX className="h-3 w-3" />
</button>
</div>
<TaskArchiveConfirmDialog
open={target !== null}
onOpenChange={(open) => {
if (!open) closeConfirm();
}}
taskTitle={target?.title ?? ""}
<TaskArchiveConfirmFlow
taskId={taskId}
executorType={target?.executorType}
isArchiving={isPending}
onConfirm={confirmArchive}
archive={archive}
confirmTestId={`${testIdPrefix}-archive-confirm`}
/>
</>
@@ -0,0 +1,37 @@
"use client";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import type { useTaskArchiveConfirm } from "@/hooks/use-task-archive-confirm";
type TaskArchiveConfirmFlowProps = {
taskId: string;
archive: ReturnType<typeof useTaskArchiveConfirm>;
/** Test id for the dialog's Archive button — the only per-surface difference. */
confirmTestId: string;
};
/**
* Binds a `useTaskArchiveConfirm` result to the shared confirmation dialog, so
* every archive entry point maps the hook onto the dialog the same way and a
* dialog prop change lands in one place.
*/
export function TaskArchiveConfirmFlow({
taskId,
archive,
confirmTestId,
}: TaskArchiveConfirmFlowProps) {
return (
<TaskArchiveConfirmDialog
open={archive.target !== null}
onOpenChange={(open) => {
if (!open) archive.closeConfirm();
}}
taskTitle={archive.target?.title ?? ""}
taskId={taskId}
executorType={archive.target?.executorType}
isArchiving={archive.isPending}
onConfirm={archive.confirmArchive}
confirmTestId={confirmTestId}
/>
);
}
+15 -4
View File
@@ -208,7 +208,12 @@ function TaskDebugOverlay({ entries }: { entries: ReturnType<typeof maybeBuildDe
return <DebugOverlay title={t("task:taskDebug")} entries={entries} />;
}
export function TaskPageInner({
/**
* Derives everything the task page renders from its inputs: the resolved task
* props plus the three prop bundles handed to the debug overlay, top bar, and
* layout. Kept out of `TaskPageInner` so that component stays a wiring shell.
*/
function useTaskPageDerivedProps({
task,
effectiveSessionId,
repository,
@@ -219,8 +224,6 @@ export function TaskPageInner({
agentctlStatus,
connectionStatus,
workflowSteps,
archivedValue,
isMobile,
showDebugOverlay,
onToggleDebugOverlay,
initialScripts,
@@ -228,7 +231,6 @@ export function TaskPageInner({
defaultLayouts,
initialLayout,
officeTaskHref,
ensureSession,
onTaskUnarchived,
}: TaskPageInnerProps) {
const taskProps = resolveTaskProps(task, repository);
@@ -275,6 +277,14 @@ export function TaskPageInner({
initialLayout,
});
return { taskProps, debugEntries, topBarProps, layoutProps };
}
export function TaskPageInner(props: TaskPageInnerProps) {
const { effectiveSessionId, task, merged, sessionPanel, archivedValue, isMobile, ensureSession } =
props;
const { taskProps, debugEntries, topBarProps, layoutProps } = useTaskPageDerivedProps(props);
return (
<TooltipProvider>
<VcsDialogsProvider
@@ -290,6 +300,7 @@ export function TaskPageInner({
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
isTaskArchived={archivedValue.isArchived}
/>
<TaskPRShortcut taskId={taskProps.taskId} />
<TaskDebugOverlay entries={debugEntries} />
@@ -0,0 +1,111 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Archiving the current task from the command palette: Cmd/Ctrl+K, type
* "archive", Enter, confirm. Mirrors the sidebar archive flow, so the same
* confirmation dialog and archive-and-switch redirect apply.
*/
test.describe("Command palette archive", () => {
test("typing 'archive' archives the current task and switches away", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const target = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Palette Archive Target",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Palette Archive Neighbour",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${target.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.taskInSidebar("Palette Archive Target")).toBeVisible({ timeout: 30_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+k`);
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
const input = dialog.getByRole("combobox");
await input.fill("archive");
const option = dialog
.getByRole("option")
.filter({ has: testPage.getByText("Archive task", { exact: true }) });
await expect(option).toBeVisible();
await option.click();
// The shared confirmation dialog names the task being archived.
const confirm = testPage.getByRole("alertdialog");
await expect(confirm.getByText(/Palette Archive Target/)).toBeVisible({ timeout: 10_000 });
await testPage.getByTestId("palette-archive-confirm").click();
await expect(session.taskInSidebar("Palette Archive Target")).not.toBeVisible({
timeout: 15_000,
});
await expect(session.taskInSidebar("Palette Archive Neighbour")).toBeVisible({
timeout: 15_000,
});
// The archived task is still reachable directly and renders as archived.
await testPage.goto(`/t/${target.id}`);
await expect(testPage.getByTestId("task-unarchive-button")).toBeVisible({ timeout: 15_000 });
});
test("an archived task offers no archive command", async ({ testPage, apiClient, seedData }) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Palette Archive Hidden",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.archiveTask(task.id);
await testPage.goto(`/t/${task.id}`);
// The Unarchive control only renders once the detail view has the archived
// task, so waiting on it keeps the shortcut press from racing hydration.
await expect(testPage.getByTestId("task-unarchive-button")).toBeVisible({ timeout: 15_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+k`);
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
// Positive control: the other task command is registered, so an empty
// archive result proves the command is hidden rather than not yet loaded.
await dialog.getByRole("combobox").fill("subtask");
await expect(
dialog
.getByRole("option")
.filter({ has: testPage.getByText("Create Subtask", { exact: true }) }),
).toBeVisible();
await dialog.getByRole("combobox").fill("archive");
await expect(
dialog
.getByRole("option")
.filter({ has: testPage.getByText("Archive task", { exact: true }) }),
).toHaveCount(0);
});
});
@@ -0,0 +1,115 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
archiveAndSwitch: vi.fn(),
toast: vi.fn(),
state: {
kanbanMulti: { snapshots: {} as Record<string, { tasks: unknown[] }> },
kanban: { tasks: [] as unknown[] },
},
}));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({ getState: () => mocks.state }),
}));
vi.mock("@/hooks/use-task-actions", () => ({
useArchiveAndSwitchTask: () => mocks.archiveAndSwitch,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
import { useTaskArchiveConfirm } from "./use-task-archive-confirm";
beforeEach(() => {
mocks.archiveAndSwitch.mockReset().mockResolvedValue(undefined);
mocks.toast.mockReset();
mocks.state.kanbanMulti.snapshots = {};
mocks.state.kanban.tasks = [
{ id: "task-A", title: "Ship the palette", primaryExecutorType: "docker" },
];
});
describe("useTaskArchiveConfirm", () => {
it("resolves the target task title and executor from the store", () => {
const { result } = renderHook(() => useTaskArchiveConfirm("task-A"));
expect(result.current.target).toBeNull();
act(() => result.current.requestArchive());
expect(result.current.target).toEqual({
title: "Ship the palette",
executorType: "docker",
});
});
it("falls back to generic copy when the task is not in the store", () => {
mocks.state.kanban.tasks = [];
const { result } = renderHook(() => useTaskArchiveConfirm("task-A"));
act(() => result.current.requestArchive());
expect(result.current.target?.title).toBe("this task");
expect(result.current.target?.executorType).toBeUndefined();
});
it("does not open a target when there is no task", () => {
const { result } = renderHook(() => useTaskArchiveConfirm(null));
act(() => result.current.requestArchive());
expect(result.current.target).toBeNull();
});
it("archives with the cascade choice and does not toast on success", async () => {
const { result } = renderHook(() => useTaskArchiveConfirm("task-A"));
await act(async () => {
await result.current.confirmArchive({ cascade: true });
});
expect(mocks.archiveAndSwitch).toHaveBeenCalledWith("task-A", { cascade: true });
expect(mocks.toast).not.toHaveBeenCalled();
expect(result.current.isPending).toBe(false);
});
it("toasts, logs the cause, and clears pending when archiving fails", async () => {
const cause = new Error("boom");
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
mocks.archiveAndSwitch.mockRejectedValueOnce(cause);
const { result } = renderHook(() => useTaskArchiveConfirm("task-A"));
await act(async () => {
await result.current.confirmArchive({ cascade: false });
});
expect(mocks.toast).toHaveBeenCalledWith({
description: "Failed to archive task",
variant: "error",
});
// The toast is generic, so the rejection reason has to reach the console.
expect(logged).toHaveBeenCalledWith("Failed to archive task:", cause);
await waitFor(() => expect(result.current.isPending).toBe(false));
logged.mockRestore();
});
it("never archives when there is no task", async () => {
const { result } = renderHook(() => useTaskArchiveConfirm(null));
await act(async () => {
await result.current.confirmArchive({ cascade: false });
});
expect(mocks.archiveAndSwitch).not.toHaveBeenCalled();
});
it("clears the target on close", () => {
const { result } = renderHook(() => useTaskArchiveConfirm("task-A"));
act(() => result.current.requestArchive());
act(() => result.current.closeConfirm());
expect(result.current.target).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppStoreApi } from "@/components/state-provider";
import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useToast } from "@/components/toast-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
export type ArchiveConfirmTarget = { title: string; executorType?: string | null };
/**
* Archive flow shared by every task-scoped archive entry point (the terminal-state
* PR banners, the command palette). Archiving always goes through the same
* preference-aware confirmation dialog: `requestArchive` only resolves the target
* task, and `TaskArchiveConfirmDialog` decides whether to prompt or bypass.
* Only failures toast; on success the archive-and-switch flow moves the user to
* the next task.
*/
export function useTaskArchiveConfirm(taskId: string | null) {
const { t } = useTranslation();
const store = useAppStoreApi();
const archiveAndSwitch = useArchiveAndSwitchTask();
const { toast } = useToast();
const [target, setTarget] = useState<ArchiveConfirmTarget | null>(null);
const [isPending, setIsPending] = useState(false);
const requestArchive = useCallback(() => {
if (!taskId) return;
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
setTarget({
title: task?.title ?? t("task:thisTask"),
executorType: task?.primaryExecutorType,
});
}, [store, t, taskId]);
const closeConfirm = useCallback(() => setTarget(null), []);
const confirmArchive = useCallback(
async ({ cascade }: { cascade: boolean }) => {
if (!taskId) return;
setIsPending(true);
try {
await archiveAndSwitch(taskId, { cascade });
} catch (error) {
// The toast is deliberately generic; keep the rejection reason in the
// console so a failed archive is diagnosable from a user's devtools.
console.error("Failed to archive task:", error);
toast({ description: t("task:failedToArchiveTask"), variant: "error" });
} finally {
setIsPending(false);
}
},
[archiveAndSwitch, t, taskId, toast],
);
return { target, requestArchive, closeConfirm, confirmArchive, isPending };
}
+14
View File
@@ -0,0 +1,14 @@
import type { TFunction } from "i18next";
/**
* Search keywords are stored as one comma-separated catalog value so a
* translator can localize the whole set in one entry. They are matched, never
* displayed; the palette itself selects commands by `id` (see
* `command-panel-footer.tsx`), so no behavior keys off this copy.
*/
export function searchKeywords(t: TFunction, key: string): string[] {
return t(key)
.split(",")
.map((keyword) => keyword.trim())
.filter(Boolean);
}
+2
View File
@@ -36,6 +36,7 @@
"cliPassthrough": "CLI passthrough",
"close": "Close",
"commandAgentsSettingsKeywords": "agents, agent settings, agent profiles, installed agents, claude",
"commandArchiveTaskKeywords": "archive, archive task, archive current task, close task, finish task, put away",
"commandConfigChatKeywords": "config chat, config mode, configure kandev, workflow configuration, mcp configuration",
"commandExecutorsSettingsKeywords": "executors, executor profiles, execution environment, environment variables, runtime, compute",
"commandGoToAllTasks": "Go to All Tasks",
@@ -53,6 +54,7 @@
"commandGroupNavigation": "Navigation",
"commandGroupPreferences": "Preferences",
"commandGroupSettings": "Settings",
"commandGroupTasks": "Tasks",
"commandPaletteMode": "Command palette mode",
"commandPreviewAddTerminalPanel": "Add Terminal Panel",
"commandPreviewCommitChanges": "Commit Changes",
+2
View File
@@ -36,6 +36,7 @@
"cliPassthrough": "ĆĹĨ ƥàśśţĥŕōũĝĥ",
"close": "Ćĺōśē",
"commandAgentsSettingsKeywords": "àĝēńţś, àĝēńţ śēţţĩńĝś, àĝēńţ ƥŕōƒĩĺēś, ĩńśţàĺĺēď àĝēńţś, ćĺàũďē",
"commandArchiveTaskKeywords": "àŕćĥĩvē, àŕćĥĩvē ţàśķ, àŕćĥĩvē ćũŕŕēńţ ţàśķ, ćĺōśē ţàśķ, ƒĩńĩśĥ ţàśķ, ƥũţ àŵàŷ",
"commandConfigChatKeywords": "ćōńƒĩĝ ćĥàţ, ćōńƒĩĝ ḿōďē, ćōńƒĩĝũŕē ķàńďēv, ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń, ḿćƥ ćōńƒĩĝũŕàţĩōń",
"commandExecutorsSettingsKeywords": "ēxēćũţōŕś, ēxēćũţōŕ ƥŕōƒĩĺēś, ēxēćũţĩōń ēńvĩŕōńḿēńţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, ŕũńţĩḿē, ćōḿƥũţē",
"commandGoToAllTasks": "Ĝō ţō Àĺĺ Ţàśķś",
@@ -53,6 +54,7 @@
"commandGroupNavigation": "Ńàvĩĝàţĩōń",
"commandGroupPreferences": "Ƥŕēƒēŕēńćēś",
"commandGroupSettings": "Śēţţĩńĝś",
"commandGroupTasks": "Ţàśķś",
"commandPaletteMode": "Ćōḿḿàńď ƥàĺēţţē ḿōďē",
"commandPreviewAddTerminalPanel": "Àďď Ţēŕḿĩńàĺ Ƥàńēĺ",
"commandPreviewCommitChanges": "Ćōḿḿĩţ Ćĥàńĝēś",