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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ćōḿḿĩţ Ćĥàńĝēś",
|
||||
|
||||
Reference in New Issue
Block a user