diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx index a30500ea63..86fdae8857 100644 --- a/apps/web/components/global-commands.tsx +++ b/apps/web/components/global-commands.tsx @@ -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["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 diff --git a/apps/web/components/session-commands.test.tsx b/apps/web/components/session-commands.test.tsx new file mode 100644 index 0000000000..d88346f23f --- /dev/null +++ b/apps/web/components/session-commands.test.tsx @@ -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[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([]); + }); +}); diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index 5db2606d00..007929e52a 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -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: , 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: , + 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; + archive: ReturnType; +}; + +/** Dialogs the task-scoped palette commands open. */ +function SessionCommandDialogs({ + activeTaskId, + activeTaskTitle, + dialogs, + archive, +}: SessionCommandDialogsProps) { + return ( + <> + + + + + ); +} + 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(() => { - 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 && ( - - )} - {activeTaskId && ( - - )} - + ); } diff --git a/apps/web/components/task/chat/pr-archive-banners.tsx b/apps/web/components/task/chat/pr-archive-banners.tsx index e48eed4df7..a91129c42c 100644 --- a/apps/web/components/task/chat/pr-archive-banners.tsx +++ b/apps/web/components/task/chat/pr-archive-banners.tsx @@ -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(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 ( <>
@@ -90,7 +46,7 @@ function ArchiveDismissBanner({
- { - if (!open) closeConfirm(); - }} - taskTitle={target?.title ?? ""} + diff --git a/apps/web/components/task/task-archive-confirm-flow.tsx b/apps/web/components/task/task-archive-confirm-flow.tsx new file mode 100644 index 0000000000..27546cc245 --- /dev/null +++ b/apps/web/components/task/task-archive-confirm-flow.tsx @@ -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; + /** 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 ( + { + if (!open) archive.closeConfirm(); + }} + taskTitle={archive.target?.title ?? ""} + taskId={taskId} + executorType={archive.target?.executorType} + isArchiving={archive.isPending} + onConfirm={archive.confirmArchive} + confirmTestId={confirmTestId} + /> + ); +} diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index 381c18f743..10b0164bba 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -208,7 +208,12 @@ function TaskDebugOverlay({ entries }: { entries: ReturnType; } -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 ( diff --git a/apps/web/e2e/tests/task/command-palette-archive.spec.ts b/apps/web/e2e/tests/task/command-palette-archive.spec.ts new file mode 100644 index 0000000000..9c98cfeafb --- /dev/null +++ b/apps/web/e2e/tests/task/command-palette-archive.spec.ts @@ -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); + }); +}); diff --git a/apps/web/hooks/use-task-archive-confirm.test.ts b/apps/web/hooks/use-task-archive-confirm.test.ts new file mode 100644 index 0000000000..366a329d16 --- /dev/null +++ b/apps/web/hooks/use-task-archive-confirm.test.ts @@ -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 }, + 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(); + }); +}); diff --git a/apps/web/hooks/use-task-archive-confirm.ts b/apps/web/hooks/use-task-archive-confirm.ts new file mode 100644 index 0000000000..b35538f129 --- /dev/null +++ b/apps/web/hooks/use-task-archive-confirm.ts @@ -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(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 }; +} diff --git a/apps/web/lib/commands/search-keywords.ts b/apps/web/lib/commands/search-keywords.ts new file mode 100644 index 0000000000..a3941989f7 --- /dev/null +++ b/apps/web/lib/commands/search-keywords.ts @@ -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); +} diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index ad1f5ca524..cc39fec0de 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -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", diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 6de7a8f5c4..a797620391 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -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": "Ćōḿḿĩţ Ćĥàńĝēś",