feat: make task actions keyboard accessible (#3767)
This commit is contained in:
@@ -13,7 +13,7 @@ export function CommandPanelDialog(props: ComponentProps<typeof CommandDialog>)
|
||||
<CommandDialog
|
||||
{...props}
|
||||
className={cn(props.className, active && "flex max-h-[calc(100dvh-2rem)] flex-col")}
|
||||
contentProps={{ ...contentProps, enterConfirms: !active }}
|
||||
contentProps={{ ...contentProps, ...props.contentProps, enterConfirms: !active }}
|
||||
>
|
||||
<MobileConfirmationHostBody>{props.children}</MobileConfirmationHostBody>
|
||||
</CommandDialog>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CommandsListContent,
|
||||
FileSearchContent,
|
||||
MODE_COMMANDS,
|
||||
MODE_COMMAND_CHILDREN,
|
||||
MODE_SEARCH_CONTENT,
|
||||
MODE_SEARCH_FILES,
|
||||
MODE_SEARCH_TASKS,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
|
||||
export {
|
||||
MODE_COMMANDS,
|
||||
MODE_COMMAND_CHILDREN,
|
||||
MODE_SEARCH_CONTENT,
|
||||
MODE_SEARCH_FILES,
|
||||
MODE_SEARCH_TASKS,
|
||||
@@ -67,7 +69,7 @@ function getEnterLabel(t: TFunction, mode: CommandPanelMode) {
|
||||
}
|
||||
|
||||
function getModeLabel(t: TFunction, mode: CommandPanelMode, inputCommand: CommandItemType | null) {
|
||||
if (mode === "input") return inputCommand?.label;
|
||||
if (mode === "input" || mode === MODE_COMMAND_CHILDREN) return inputCommand?.label;
|
||||
if (mode === MODE_SEARCH_TASKS) return t("common:tasks");
|
||||
if (mode === MODE_SEARCH_FILES) return t("common:files");
|
||||
if (mode === MODE_SEARCH_CONTENT) return t("common:contents");
|
||||
@@ -105,13 +107,14 @@ function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) {
|
||||
<KbdGroup>
|
||||
{/* A key name, not copy — it labels the physical key. */}
|
||||
<Kbd>esc</Kbd>
|
||||
<span>{t("common:close")}</span>
|
||||
<span>{t(mode === MODE_COMMAND_CHILDREN ? "common:back" : "common:close")}</span>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type CommandPanelViewProps = {
|
||||
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
mode: CommandPanelMode;
|
||||
@@ -180,8 +183,8 @@ function CommandPanelInputHeader({
|
||||
{!isTopLevelMode && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 pl-2 flex min-h-10 cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={t("common:back")}
|
||||
className="shrink-0 pl-2 flex min-h-10 [@media(pointer:coarse)]:min-h-11 cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>{modeLabel}</span>
|
||||
@@ -237,7 +240,7 @@ function CommandPanelResultList(props: CommandPanelViewProps) {
|
||||
return (
|
||||
<CommandList>
|
||||
{confirmationCommand && <CommandPanelConfirmation command={confirmationCommand} />}
|
||||
{mode === MODE_COMMANDS && (
|
||||
{(mode === MODE_COMMANDS || mode === MODE_COMMAND_CHILDREN) && (
|
||||
<CommandsListContent
|
||||
commands={visibleCommands}
|
||||
grouped={visibleGroups}
|
||||
@@ -320,6 +323,7 @@ export function CommandPanelView(props: CommandPanelViewProps) {
|
||||
<CommandPanelDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
contentProps={{ onEscapeKeyDown: props.onEscapeKeyDown }}
|
||||
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
|
||||
>
|
||||
<Command
|
||||
@@ -343,6 +347,11 @@ export function CommandPanelView(props: CommandPanelViewProps) {
|
||||
onValueChange={setSelectedValue}
|
||||
>
|
||||
<CommandPanelInputHeader {...renderedProps} />
|
||||
{mode === MODE_COMMAND_CHILDREN && props.inputCommand?.context && (
|
||||
<div className="truncate border-b px-3 py-1 text-xs text-muted-foreground">
|
||||
{props.inputCommand.context}
|
||||
</div>
|
||||
)}
|
||||
<CommandPanelResultList {...renderedProps} />
|
||||
<CommandPanelFooter mode={renderedProps.mode} />
|
||||
</Command>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import { workflowMoveShortcutLabel } from "./task/use-workflow-move-submit";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale";
|
||||
import { IconArchive, IconArrowRight, IconLoader2 } from "@tabler/icons-react";
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
type CommandPanelLiveTask,
|
||||
} from "@/lib/commands/task-result-activity";
|
||||
|
||||
export const MODE_COMMAND_CHILDREN: CommandPanelMode = "command-children";
|
||||
export const MODE_COMMANDS: CommandPanelMode = "commands";
|
||||
export const MODE_SEARCH_TASKS: CommandPanelMode = "search-tasks";
|
||||
export const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
|
||||
@@ -66,8 +69,14 @@ function CommandItemRow({
|
||||
key={cmd.id}
|
||||
value={cmd.id}
|
||||
keywords={getCommandSearchTerms(cmd)}
|
||||
onSelect={() => onSelect(cmd)}
|
||||
className="[@media(pointer:coarse)]:min-h-12"
|
||||
disabled={cmd.disabled}
|
||||
className={cn(
|
||||
"cursor-pointer [@media(pointer:coarse)]:min-h-12",
|
||||
cmd.destructive && "text-destructive",
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (!cmd.disabled) onSelect(cmd);
|
||||
}}
|
||||
>
|
||||
{cmd.icon && (
|
||||
<span
|
||||
@@ -87,7 +96,8 @@ function CommandItemRow({
|
||||
)}
|
||||
</span>
|
||||
{cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>}
|
||||
{cmd.enterMode && (
|
||||
{cmd.immediateAction && <CommandShortcut>{workflowMoveShortcutLabel()}</CommandShortcut>}
|
||||
{(cmd.enterMode || cmd.children || cmd.immediateAction) && (
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
<IconArrowRight className="size-3" />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect } from "react";
|
||||
import { StateProvider } from "./state-provider";
|
||||
import { CommandRegistryProvider, useCommandPanelOpen } from "@/lib/commands/command-registry";
|
||||
import { useRegisterCommands } from "@/hooks/use-register-commands";
|
||||
import { CommandPanel } from "./command-panel";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { CommandPanelView, type CommandPanelViewProps } from "./command-panel-footer";
|
||||
import { findFirstMatchingCommand, selectCommandSearchResult } from "@/lib/commands/search";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const disabledCommand = { id: "duplicate", label: "Duplicate", group: "Tasks", disabled: true };
|
||||
|
||||
it("never chooses a disabled command as the actionable result", () => {
|
||||
const commands: CommandItem[] = [disabledCommand];
|
||||
expect(findFirstMatchingCommand(commands, "Duplicate")).toBeUndefined();
|
||||
expect(
|
||||
selectCommandSearchResult({
|
||||
commands,
|
||||
search: "Duplicate",
|
||||
preferredValue: "duplicate",
|
||||
taskResultValues: [],
|
||||
commandsLeadResults: true,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders disabled commands without allowing selection", () => {
|
||||
const handleSelect = vi.fn();
|
||||
const props: CommandPanelViewProps = {
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
mode: "commands",
|
||||
inputCommand: null,
|
||||
selectedValue: "",
|
||||
setSelectedValue: vi.fn(),
|
||||
search: "Duplicate",
|
||||
setSearch: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
onScopeChange: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
fileResults: [],
|
||||
isSearchingFiles: false,
|
||||
handleFileSelect: vi.fn(),
|
||||
contentResults: [],
|
||||
isSearchingContent: false,
|
||||
contentSearchError: null,
|
||||
activeSessionId: null,
|
||||
workspaceSearchAvailable: false,
|
||||
handleContentSelect: vi.fn(),
|
||||
commands: [disabledCommand],
|
||||
grouped: [["Tasks", [disabledCommand]]],
|
||||
handleSelect,
|
||||
isSearching: false,
|
||||
taskResults: [],
|
||||
stepMap: new Map(),
|
||||
repoMap: new Map(),
|
||||
liveTasksById: new Map(),
|
||||
lastStepIdByWorkflowId: new Map(),
|
||||
handleTaskSelect: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<StateProvider>
|
||||
<CommandPanelView {...props} />
|
||||
</StateProvider>,
|
||||
);
|
||||
const option = screen.getByRole("option", { name: "Duplicate" });
|
||||
fireEvent.click(option);
|
||||
expect(handleSelect).not.toHaveBeenCalled();
|
||||
expect(option.getAttribute("aria-disabled")).toBe("true");
|
||||
});
|
||||
|
||||
const review = vi.fn();
|
||||
const commands = [
|
||||
{
|
||||
id: "move",
|
||||
label: "Move to",
|
||||
group: "Tasks",
|
||||
children: [{ id: "review", label: "Review", group: "Steps", action: review }],
|
||||
},
|
||||
];
|
||||
function Register({ items = commands }: { items?: CommandItem[] }) {
|
||||
useRegisterCommands(items);
|
||||
const { setOpen } = useCommandPanelOpen();
|
||||
useEffect(() => {
|
||||
setOpen(true);
|
||||
}, [setOpen]);
|
||||
return <CommandPanel />;
|
||||
}
|
||||
it("navigates into current child commands and back without executing the parent", async () => {
|
||||
render(
|
||||
<StateProvider>
|
||||
<CommandRegistryProvider>
|
||||
<Register />
|
||||
</CommandRegistryProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Move to" }));
|
||||
expect(await screen.findByRole("option", { name: "Review" })).toBeTruthy();
|
||||
expect(screen.getByText("esc").parentElement?.textContent).toContain("Back");
|
||||
expect(review).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||
expect(await screen.findByRole("option", { name: "Move to" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drops stale nested choices when their registered parent disappears", async () => {
|
||||
const view = render(
|
||||
<StateProvider>
|
||||
<CommandRegistryProvider>
|
||||
<Register />
|
||||
</CommandRegistryProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Move to" }));
|
||||
expect(await screen.findByRole("option", { name: "Review" })).toBeTruthy();
|
||||
const replacement = [{ id: "pin", label: "Pin", group: "Tasks", action: vi.fn() }];
|
||||
view.rerender(
|
||||
<StateProvider>
|
||||
<CommandRegistryProvider>
|
||||
<Register items={replacement} />
|
||||
</CommandRegistryProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
expect(await screen.findByRole("option", { name: "Pin" })).toBeTruthy();
|
||||
expect(screen.queryByRole("option", { name: "Review" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["ctrlKey", "metaKey"])(
|
||||
"runs a destination's immediate action with %s+Enter",
|
||||
async (modifier) => {
|
||||
const openOptions = vi.fn();
|
||||
const moveNow = vi.fn();
|
||||
const items: CommandItem[] = [
|
||||
{
|
||||
id: "destination",
|
||||
label: "Verify",
|
||||
group: "Steps",
|
||||
action: openOptions,
|
||||
immediateAction: moveNow,
|
||||
},
|
||||
];
|
||||
render(
|
||||
<StateProvider>
|
||||
<CommandRegistryProvider>
|
||||
<Register items={items} />
|
||||
</CommandRegistryProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
await screen.findByRole("option", { name: /Verify/ });
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Enter", [modifier]: true });
|
||||
expect(moveNow).toHaveBeenCalledOnce();
|
||||
expect(openOptions).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "@/lib/routing/client-router";
|
||||
import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry";
|
||||
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
|
||||
import { selectCommandSearchResult, selectContentSearchResult } from "@/lib/commands/search";
|
||||
import { useCommandChildren } from "@/hooks/use-command-children";
|
||||
import { useCommandPanelShortcuts } from "@/hooks/use-command-panel-shortcuts";
|
||||
import { useContentSearchResultOpener } from "@/hooks/use-content-search-result-opener";
|
||||
import { useWorkspaceContentSearch } from "@/hooks/domains/session/use-workspace-content-search";
|
||||
@@ -27,6 +28,7 @@ import { useInlineTaskSearchEffect } from "@/hooks/use-command-panel-task-result
|
||||
import {
|
||||
CommandPanelView,
|
||||
MODE_COMMANDS,
|
||||
MODE_COMMAND_CHILDREN,
|
||||
MODE_SEARCH_CONTENT,
|
||||
MODE_SEARCH_FILES,
|
||||
MODE_SEARCH_TASKS,
|
||||
@@ -34,6 +36,31 @@ import {
|
||||
getTaskResultValue,
|
||||
} from "@/components/command-panel-footer";
|
||||
|
||||
function handleImmediateCommand(
|
||||
event: React.KeyboardEvent,
|
||||
commands: CommandItemType[],
|
||||
selectedValue: string,
|
||||
close: () => void,
|
||||
) {
|
||||
if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
event.repeat ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.keyCode === 229 ||
|
||||
event.altKey ||
|
||||
event.shiftKey
|
||||
)
|
||||
return true;
|
||||
const command = commands.find((item) => item.id === selectedValue);
|
||||
if (command?.immediateAction && !command.disabled) {
|
||||
close();
|
||||
command.immediateAction();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCommandPanelState(mode: CommandPanelMode, setMode: (mode: CommandPanelMode) => void) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [inputCommand, setInputCommand] = useState<CommandItemType | null>(null);
|
||||
@@ -227,15 +254,16 @@ function useFirstResultSelection(
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === MODE_COMMANDS) {
|
||||
if (mode === MODE_COMMANDS || mode === MODE_COMMAND_CHILDREN) {
|
||||
// Matching commands render above the task preview once there is a query,
|
||||
// so the default highlight has to follow that order: Enter on "archive"
|
||||
// must run the Archive command, not the first task the query fuzzy-matched.
|
||||
// Task results arrive 300ms behind the keystroke, so this stays a
|
||||
// functional update: a row the user arrow-keyed to in the meantime must
|
||||
// survive the results landing rather than snap back to the default.
|
||||
const commandsLeadResults = Boolean(search.trim());
|
||||
const taskResultValues = taskResults.map(getTaskResultValue);
|
||||
const commandsLeadResults = mode === MODE_COMMAND_CHILDREN || Boolean(search.trim());
|
||||
const taskResultValues =
|
||||
mode === MODE_COMMAND_CHILDREN ? [] : taskResults.map(getTaskResultValue);
|
||||
setSelectedValue((current) =>
|
||||
selectCommandSearchResult({
|
||||
commands,
|
||||
@@ -318,6 +346,7 @@ function useCommandPanelHandlers({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(cmd: CommandItemType) => {
|
||||
if (cmd.disabled) return;
|
||||
if (cmd.enterMode) {
|
||||
if (cmd.enterMode === "input") setInputCommand(cmd);
|
||||
setMode(cmd.enterMode);
|
||||
@@ -430,7 +459,7 @@ function useCommandPanelRepositories(workspaceId: string | null) {
|
||||
// eslint-disable-next-line max-lines-per-function -- composition root keeps hook ordering and view wiring together.
|
||||
export function CommandPanel() {
|
||||
const { open, setOpen, mode: panelMode, setMode, modeRequestVersion } = useCommandPanelOpen();
|
||||
const commands = useCommands();
|
||||
const registeredCommands = useCommands();
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
tasks: liveTasksById,
|
||||
@@ -461,6 +490,18 @@ export function CommandPanel() {
|
||||
setSelectedValue,
|
||||
setSearch,
|
||||
} = state;
|
||||
const children = useCommandChildren({
|
||||
commands: registeredCommands,
|
||||
open,
|
||||
contextKey: `${workspaceId}:${activeTaskId}`,
|
||||
mode,
|
||||
setMode,
|
||||
search,
|
||||
setSearch,
|
||||
selection: selectedValue,
|
||||
setSelection: setSelectedValue,
|
||||
});
|
||||
const commands = children.commands;
|
||||
useCommandPanelEffects({
|
||||
open,
|
||||
state,
|
||||
@@ -503,14 +544,34 @@ export function CommandPanel() {
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
mode={mode}
|
||||
inputCommand={inputCommand}
|
||||
inputCommand={children.parent ?? inputCommand}
|
||||
selectedValue={selectedValue}
|
||||
setSelectedValue={setSelectedValue}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
handleKeyDown={handlers.handleKeyDown}
|
||||
handleKeyDown={(event) => {
|
||||
if (handleImmediateCommand(event, commands, selectedValue, () => setOpen(false))) return;
|
||||
if (
|
||||
mode === MODE_COMMAND_CHILDREN &&
|
||||
(event.key === "Escape" || (event.key === "Backspace" && !search))
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
children.back();
|
||||
return;
|
||||
}
|
||||
handlers.handleKeyDown(event);
|
||||
}}
|
||||
onEscapeKeyDown={
|
||||
mode === MODE_COMMAND_CHILDREN
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
children.back();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onScopeChange={handlers.onScopeChange}
|
||||
goBack={handlers.goBack}
|
||||
goBack={mode === MODE_COMMAND_CHILDREN ? children.back : handlers.goBack}
|
||||
fileResults={fileResults}
|
||||
isSearchingFiles={isSearchingFiles}
|
||||
handleFileSelect={handlers.handleFileSelect}
|
||||
@@ -522,9 +583,16 @@ export function CommandPanel() {
|
||||
handleContentSelect={handleContentSelect}
|
||||
commands={commands}
|
||||
grouped={handlers.grouped}
|
||||
handleSelect={handlers.handleSelect}
|
||||
isSearching={isSearching}
|
||||
taskResults={taskResults}
|
||||
handleSelect={(command) => {
|
||||
if (command.disabled) return;
|
||||
if (command.children) {
|
||||
children.enter(command);
|
||||
return;
|
||||
}
|
||||
handlers.handleSelect(command);
|
||||
}}
|
||||
isSearching={mode === MODE_COMMAND_CHILDREN ? false : isSearching}
|
||||
taskResults={mode === MODE_COMMAND_CHILDREN ? [] : taskResults}
|
||||
stepMap={handlers.stepMap}
|
||||
repoMap={handlers.repoMap}
|
||||
liveTasksById={liveTasksById}
|
||||
|
||||
@@ -74,17 +74,10 @@ describe("buildTaskCommands", () => {
|
||||
expect(requestArchive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers the archive confirmation dismiss callback", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const archive = build({
|
||||
onArchiveConfirmationDismiss: onDismiss,
|
||||
} as Partial<Parameters<typeof buildTaskCommands>[0]>).find(
|
||||
(cmd) => cmd.id === ARCHIVE_COMMAND_ID,
|
||||
) as CommandItem & { onConfirmationDismiss?: () => void };
|
||||
|
||||
archive.onConfirmationDismiss?.();
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
it("closes the palette before opening standalone archive confirmation", () => {
|
||||
const archive = build().find((cmd) => cmd.id === ARCHIVE_COMMAND_ID);
|
||||
expect(archive?.keepOpen).not.toBe(true);
|
||||
expect(archive?.confirmation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the archive command for an archived task", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -196,8 +196,6 @@ type TaskCommandOptions = {
|
||||
openNewAgent: () => void;
|
||||
openSubtask: () => void;
|
||||
requestArchive: () => void;
|
||||
archiveConfirmation?: ReactNode;
|
||||
onArchiveConfirmationDismiss?: () => void;
|
||||
};
|
||||
|
||||
export function buildTaskCommands({
|
||||
@@ -207,8 +205,6 @@ export function buildTaskCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
onArchiveConfirmationDismiss,
|
||||
}: TaskCommandOptions): CommandItem[] {
|
||||
if (!activeTaskId) return [];
|
||||
const items: CommandItem[] = [
|
||||
@@ -239,9 +235,6 @@ export function buildTaskCommands({
|
||||
icon: <IconArchive className="size-3.5" />,
|
||||
keywords: searchKeywords(t, "common:commandArchiveTaskKeywords"),
|
||||
action: requestArchive,
|
||||
keepOpen: true,
|
||||
confirmation: archiveConfirmation,
|
||||
onConfirmationDismiss: onArchiveConfirmationDismiss,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -348,7 +341,7 @@ function useArchiveCommandConfirmation(archive: ReturnType<typeof useTaskArchive
|
||||
return (
|
||||
<TaskArchiveConfirmation
|
||||
open
|
||||
inline
|
||||
forceDialog
|
||||
anchorRef={archiveAnchorRef}
|
||||
taskId={archive.target.id}
|
||||
taskTitle={archive.target.title}
|
||||
@@ -436,8 +429,6 @@ export function SessionCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
onArchiveConfirmationDismiss: archive.closeConfirm,
|
||||
}),
|
||||
];
|
||||
return items.map((cmd) => ({ ...cmd, priority: 0 }));
|
||||
@@ -460,8 +451,6 @@ export function SessionCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
archive.closeConfirm,
|
||||
]);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
@@ -469,10 +458,13 @@ export function SessionCommands({
|
||||
if (!activeTaskId) return null;
|
||||
|
||||
return (
|
||||
<SessionCommandDialogs
|
||||
activeTaskId={activeTaskId}
|
||||
activeTaskTitle={activeTaskTitle}
|
||||
dialogs={dialogs}
|
||||
/>
|
||||
<>
|
||||
<SessionCommandDialogs
|
||||
activeTaskId={activeTaskId}
|
||||
activeTaskTitle={activeTaskTitle}
|
||||
dialogs={dialogs}
|
||||
/>
|
||||
{archiveConfirmation}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useTaskMoveChoices } from "./task-command-choices";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("omits workflows without destinations while preserving both destination actions", () => {
|
||||
const openMoveOptions = vi.fn();
|
||||
const moveImmediately = vi.fn();
|
||||
const target = { id: "review", title: "Review", color: "bg-blue-500" };
|
||||
const { result } = renderHook(() =>
|
||||
useTaskMoveChoices({
|
||||
task: { id: "task", title: "Task", workflowId: "current" },
|
||||
workflows: [
|
||||
{ id: "current", name: "Current" },
|
||||
{ id: "empty", name: "Empty" },
|
||||
{ id: "missing", name: "Missing" },
|
||||
{ id: "target", name: "Target" },
|
||||
],
|
||||
stepsByWorkflowId: { empty: [], target: [target] },
|
||||
openMoveOptions,
|
||||
moveImmediately,
|
||||
}),
|
||||
);
|
||||
expect(result.current.workflows.map((item) => item.label)).toEqual(["Target"]);
|
||||
const destination = result.current.workflows[0].children![0];
|
||||
destination.action?.();
|
||||
destination.immediateAction?.();
|
||||
expect(openMoveOptions).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
|
||||
expect(moveImmediately).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
|
||||
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
|
||||
import { useNestTask } from "@/hooks/use-nest-task";
|
||||
import { useAppStore } from "./state-provider";
|
||||
import { TASK_COLORS, TASK_COLOR_LABEL_KEYS, TASK_COLOR_BAR_CLASS } from "@/lib/task-colors";
|
||||
import { TASK_PRIORITY_TOKENS, TASK_PRIORITY_LABEL_KEYS } from "@/lib/tasks/task-priority";
|
||||
import { computeNestCandidates } from "@/lib/sidebar/nest-candidates";
|
||||
import { resolvePluginIcon } from "@/lib/plugins/icons";
|
||||
import {
|
||||
taskMoveOptions,
|
||||
type TaskMoveStep,
|
||||
type TaskMoveWorkflow,
|
||||
} from "./task/task-move-context-menu";
|
||||
import { useTaskPluginLinkActions } from "./task/task-session-sidebar-link-actions";
|
||||
import { useTaskPluginPrimaryMenuEntries } from "./task/task-switcher-plugin-menu-items";
|
||||
import {
|
||||
selectTaskLinkActions,
|
||||
taskLinkMenuOptions,
|
||||
type TaskLinkHandlers,
|
||||
} from "./task/task-switcher-link-menu";
|
||||
import type { TaskSwitcherItem } from "./task/task-switcher-types";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type ChoiceOptions = {
|
||||
task: TaskSwitcherItem;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
linkHandlers: TaskLinkHandlers;
|
||||
openMoveOptions: (step: TaskMoveStep) => void;
|
||||
moveImmediately: (step: TaskMoveStep) => void;
|
||||
};
|
||||
|
||||
export function useTaskCommandChoices(options: ChoiceOptions) {
|
||||
const { task, linkHandlers } = options;
|
||||
const { t } = useTranslation();
|
||||
const group = t("common:commandGroupTasks");
|
||||
const setColor = useSetTaskColor();
|
||||
const currentColor = useTaskColor(task.id);
|
||||
const updatePriority = useUpdateTaskPriority();
|
||||
const pluginLinks = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []);
|
||||
const pluginEntries = useTaskPluginPrimaryMenuEntries(task);
|
||||
const item = (id: string, label: string, action?: () => void): CommandItem => ({
|
||||
id,
|
||||
label,
|
||||
group,
|
||||
action,
|
||||
});
|
||||
const colors: CommandItem[] = TASK_COLORS.map((color) => ({
|
||||
...item(`task-color-${color}`, t(TASK_COLOR_LABEL_KEYS[color]), () => setColor(task.id, color)),
|
||||
icon: (
|
||||
<span aria-hidden className={`block size-2.5 rounded-full ${TASK_COLOR_BAR_CLASS[color]}`} />
|
||||
),
|
||||
}));
|
||||
colors.push({
|
||||
...item("task-color-none", t("task:groupNone"), () => setColor(task.id, null)),
|
||||
disabled: !currentColor,
|
||||
});
|
||||
if (task.automaticColorSource)
|
||||
colors.unshift({
|
||||
...item(
|
||||
"task-color-source",
|
||||
t("task:automaticColorSource", { rule: task.automaticColorSource.label }),
|
||||
),
|
||||
disabled: true,
|
||||
});
|
||||
const priorities = TASK_PRIORITY_TOKENS.map((priority) => ({
|
||||
...item(
|
||||
`task-priority-${priority}`,
|
||||
t(TASK_PRIORITY_LABEL_KEYS[priority]),
|
||||
() => void updatePriority(task.id, priority),
|
||||
),
|
||||
context: task.priority === priority ? t("kanban:current") : undefined,
|
||||
}));
|
||||
const links = taskLinkMenuOptions(selectTaskLinkActions(task, noop, linkHandlers)).map(
|
||||
({ id, labelKey, Icon, onSelect }) => ({
|
||||
...item(`task-link-${id}`, t(labelKey), onSelect),
|
||||
icon: <Icon className="size-3.5" />,
|
||||
}),
|
||||
);
|
||||
links.push(
|
||||
...pluginLinks.map((link) => ({
|
||||
...item(`task-link-plugin-${link.id}`, link.label, link.onSelect),
|
||||
icon: createElement(resolvePluginIcon(link.icon), { className: "size-3.5" }),
|
||||
})),
|
||||
);
|
||||
const plugins = pluginEntries.flatMap((entry): CommandItem[] =>
|
||||
entry.kind === "item" && typeof entry.label === "string"
|
||||
? [
|
||||
{
|
||||
...item(entry.key, entry.label, entry.onSelect),
|
||||
disabled: entry.disabled,
|
||||
icon: entry.icon,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
colors,
|
||||
priorities,
|
||||
links,
|
||||
plugins,
|
||||
nesting: useTaskNestChoices(task),
|
||||
...useTaskMoveChoices(options),
|
||||
};
|
||||
}
|
||||
|
||||
function useTaskNestChoices(task: TaskSwitcherItem): CommandItem[] {
|
||||
const { t } = useTranslation();
|
||||
const nestTask = useNestTask();
|
||||
const tasks = useAppStore((state) =>
|
||||
task.workflowId
|
||||
? (state.kanbanMulti.snapshots[task.workflowId]?.tasks ??
|
||||
(state.kanban.workflowId === task.workflowId ? state.kanban.tasks : undefined))
|
||||
: undefined,
|
||||
);
|
||||
const group = t("task:nestUnder");
|
||||
const candidates = computeNestCandidates(tasks ?? [], task.id);
|
||||
const items: CommandItem[] = candidates.map((candidate) => ({
|
||||
id: `task-nest-${candidate.id}`,
|
||||
label: candidate.title,
|
||||
group,
|
||||
action: () => {
|
||||
if (task.workflowId) void nestTask(task.id, task.workflowId, candidate.id);
|
||||
},
|
||||
}));
|
||||
if (!items.length)
|
||||
items.push({ id: "task-nest-empty", label: t("task:noOtherTasks"), group, disabled: true });
|
||||
if (task.parentTaskId)
|
||||
items.unshift({
|
||||
id: "task-unnest",
|
||||
label: t("task:unNestRemoveParent"),
|
||||
group,
|
||||
action: () => {
|
||||
if (task.workflowId) void nestTask(task.id, task.workflowId, null);
|
||||
},
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useTaskMoveChoices({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
openMoveOptions,
|
||||
moveImmediately,
|
||||
}: Omit<ChoiceOptions, "linkHandlers">) {
|
||||
const { t } = useTranslation();
|
||||
const { currentSteps, targets } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId);
|
||||
const group = t("task:moveTo");
|
||||
const choice = (step: TaskMoveStep, workflowId: string, group: string): CommandItem => {
|
||||
const target = { ...step, workflow_id: workflowId };
|
||||
return {
|
||||
id: `task-move-${workflowId}-${step.id}`,
|
||||
label: step.title,
|
||||
group,
|
||||
icon: (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`block size-2.5 rounded-full ${step.color || "bg-slate-500"}`}
|
||||
/>
|
||||
),
|
||||
action: () => openMoveOptions(target),
|
||||
immediateAction: () => moveImmediately(target),
|
||||
};
|
||||
};
|
||||
const steps = currentSteps
|
||||
.filter((step) => step.id !== task.workflowStepId)
|
||||
.map((step) => choice(step, task.workflowId!, group));
|
||||
const workflowChoices: CommandItem[] = targets
|
||||
.filter((workflow) => (stepsByWorkflowId[workflow.id]?.length ?? 0) > 0)
|
||||
.map((workflow) => ({
|
||||
id: `task-workflow-${workflow.id}`,
|
||||
label: workflow.name,
|
||||
group: t("task:sendToWorkflow"),
|
||||
children: (stepsByWorkflowId[workflow.id] ?? []).map((step) =>
|
||||
choice(step, workflow.id, workflow.name),
|
||||
),
|
||||
}));
|
||||
return { steps, workflows: workflowChoices };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconPalette,
|
||||
IconFlag,
|
||||
IconEdit,
|
||||
IconPencil,
|
||||
IconCopy,
|
||||
IconSubtask,
|
||||
IconLink,
|
||||
IconUnlink,
|
||||
IconArrowRight,
|
||||
IconLogicBuffer,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { taskRowActionAvailability } from "./task/task-row-action-availability";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
import type { TaskSwitcherItem } from "./task/task-switcher-types";
|
||||
import type { TFunction } from "i18next";
|
||||
export type TaskCommandContext = {
|
||||
task: TaskSwitcherItem;
|
||||
t: TFunction;
|
||||
isPinned: boolean;
|
||||
disabled?: boolean;
|
||||
colors: CommandItem[];
|
||||
priorities: CommandItem[];
|
||||
links: CommandItem[];
|
||||
nesting: CommandItem[];
|
||||
steps: CommandItem[];
|
||||
workflows: CommandItem[];
|
||||
plugins: CommandItem[];
|
||||
onPin: () => void;
|
||||
onEdit: () => void;
|
||||
onRename: () => void;
|
||||
onDetach: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
export function buildSidebarTaskCommands(ctx: TaskCommandContext): CommandItem[] {
|
||||
const { task, t } = ctx;
|
||||
const available = taskRowActionAvailability(task);
|
||||
const group = t("common:commandGroupTasks");
|
||||
const command = (
|
||||
id: string,
|
||||
label: string,
|
||||
Icon: typeof IconPin,
|
||||
action?: () => void,
|
||||
): CommandItem => ({
|
||||
id,
|
||||
label,
|
||||
group,
|
||||
icon: <Icon className="size-3.5" />,
|
||||
action,
|
||||
disabled: ctx.disabled,
|
||||
});
|
||||
const items = [
|
||||
command(
|
||||
"task-pin",
|
||||
t(ctx.isPinned ? "task:unpin" : "task:annotationPin"),
|
||||
ctx.isPinned ? IconPinFilled : IconPin,
|
||||
ctx.onPin,
|
||||
),
|
||||
];
|
||||
const choices = (id: string, label: string, Icon: typeof IconPin, children: CommandItem[]) => {
|
||||
if (children.length) items.push({ ...command(id, label, Icon), children });
|
||||
};
|
||||
if (available.mark) {
|
||||
choices("task-color", t("task:color"), IconPalette, ctx.colors);
|
||||
choices("task-priority", t("kanban:priority"), IconFlag, ctx.priorities);
|
||||
}
|
||||
if (available.edit) items.push(command("task-edit", t("common:edit"), IconEdit, ctx.onEdit));
|
||||
items.push(command("task-rename", t("task:rename"), IconPencil, ctx.onRename));
|
||||
if (available.mark)
|
||||
items.push({ ...command("task-duplicate", t("settings:duplicate"), IconCopy), disabled: true });
|
||||
if (available.nest) choices("task-nest", t("task:nestUnder"), IconSubtask, ctx.nesting);
|
||||
choices("task-link", t("task:link"), IconLink, ctx.links);
|
||||
if (available.detach)
|
||||
items.push(command("task-detach", t("task:detachFromParent"), IconUnlink, ctx.onDetach));
|
||||
if (available.move) {
|
||||
choices("task-move", t("task:moveTo"), IconArrowRight, ctx.steps);
|
||||
choices("task-send-workflow", t("task:sendToWorkflow"), IconLogicBuffer, ctx.workflows);
|
||||
}
|
||||
items.push(...ctx.plugins);
|
||||
items.push({
|
||||
...command("task-delete", t("task:delete"), IconTrash, ctx.onDelete),
|
||||
destructive: true,
|
||||
});
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
priority: 0,
|
||||
disabled: item.disabled || ctx.disabled,
|
||||
context: task.title,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { buildSidebarTaskCommands, type TaskCommandContext } from "./task-command-items";
|
||||
const PLUGIN_ACTION = "plugin-primary";
|
||||
const child = { id: "child", label: "Choice", group: "Tasks", action: vi.fn() };
|
||||
function context(overrides: Partial<TaskCommandContext> = {}): TaskCommandContext {
|
||||
return {
|
||||
task: {
|
||||
id: "task-a",
|
||||
title: "Task A",
|
||||
workflowId: "workflow-a",
|
||||
workflowStepId: "work",
|
||||
parentTaskId: "parent",
|
||||
},
|
||||
t: t as TFunction,
|
||||
isPinned: false,
|
||||
colors: [child],
|
||||
priorities: [child],
|
||||
links: [child],
|
||||
nesting: [child],
|
||||
steps: [child],
|
||||
workflows: [child],
|
||||
plugins: [{ ...child, id: PLUGIN_ACTION }],
|
||||
onPin: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
onDetach: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
describe("sidebar task command parity", () => {
|
||||
it("offers every missing single-task sidebar action", () => {
|
||||
expect(buildSidebarTaskCommands(context()).map((command) => command.id)).toEqual([
|
||||
"task-pin",
|
||||
"task-color",
|
||||
"task-priority",
|
||||
"task-edit",
|
||||
"task-rename",
|
||||
"task-duplicate",
|
||||
"task-nest",
|
||||
"task-link",
|
||||
"task-detach",
|
||||
"task-move",
|
||||
"task-send-workflow",
|
||||
PLUGIN_ACTION,
|
||||
"task-delete",
|
||||
]);
|
||||
});
|
||||
it("keeps Duplicate disabled and identifies the task", () => {
|
||||
const commands = buildSidebarTaskCommands(context());
|
||||
expect(commands.find((c) => c.id === "task-duplicate")?.disabled).toBe(true);
|
||||
expect(commands.every((c) => c.context === "Task A")).toBe(true);
|
||||
});
|
||||
it("preserves archived-task eligibility and conditional detach", () => {
|
||||
const ctx = context();
|
||||
ctx.task = { ...ctx.task, isArchived: true, parentTaskId: undefined };
|
||||
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).toEqual([
|
||||
"task-pin",
|
||||
"task-rename",
|
||||
"task-link",
|
||||
PLUGIN_ACTION,
|
||||
"task-delete",
|
||||
]);
|
||||
});
|
||||
it("invokes existing task handlers and switches pin labels", () => {
|
||||
const ctx = context({ isPinned: true });
|
||||
const commands = buildSidebarTaskCommands(ctx);
|
||||
const pin = commands.find((c) => c.id === "task-pin");
|
||||
expect(pin?.label).toBe(t("task:unpin"));
|
||||
pin?.action?.();
|
||||
commands.find((c) => c.id === "task-delete")?.action?.();
|
||||
expect(ctx.onPin).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("removes vanished plugin actions and unavailable choices on rebuild", () => {
|
||||
const ctx = context({ plugins: [], links: [], steps: [], workflows: [] });
|
||||
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).not.toContain(PLUGIN_ACTION);
|
||||
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).not.toContain("task-move");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore, useAppStoreApi } from "./state-provider";
|
||||
import { useRegisterCommands } from "@/hooks/use-register-commands";
|
||||
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
|
||||
import { useSidebarData, useTaskRowActions } from "./task/task-session-sidebar";
|
||||
import { SidebarDialogs } from "./task/task-session-sidebar-dialogs";
|
||||
import { useSidebarTaskLinking } from "./task/task-session-sidebar-task-linking";
|
||||
import { TaskMoveOptionsSurface, useTaskMoveOptions } from "./task/task-move-context-menu";
|
||||
import { buildSidebarTaskCommands } from "./task-command-items";
|
||||
import { useTaskCommandChoices } from "./task-command-choices";
|
||||
import type { TaskSwitcherItem } from "./task/task-switcher-types";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
|
||||
/** One task action host, shared by desktop and phone and independent of sessions. */
|
||||
export function TaskCommands() {
|
||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const data = useSidebarData(workspaceId);
|
||||
const task = data.tasksWithRepositories.find((item) => item.id === data.activeTaskId);
|
||||
if (!workspaceId || !task || data.workspaceContextAccessDenied) return null;
|
||||
return (
|
||||
<TaskCommandsForTask
|
||||
key={`${workspaceId}:${task.id}`}
|
||||
task={task}
|
||||
workspaceId={workspaceId}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskCommandsForTaskProps = {
|
||||
task: TaskSwitcherItem;
|
||||
workspaceId: string;
|
||||
data: ReturnType<typeof useSidebarData>;
|
||||
};
|
||||
|
||||
function TaskCommandsForTask({ task, workspaceId, data }: TaskCommandsForTaskProps) {
|
||||
const { t } = useTranslation();
|
||||
const store = useAppStoreApi();
|
||||
const actions = useTaskRowActions(store);
|
||||
const prefs = useSidebarTaskPrefs();
|
||||
const linkHandlers = useSidebarTaskLinking(workspaceId, actions);
|
||||
const repositories = useAppStore((state) => state.repositories.itemsByWorkspaceId[workspaceId]);
|
||||
const move = useTaskMoveOptions({
|
||||
taskId: task.id,
|
||||
workflowId: task.workflowId,
|
||||
steps: data.stepsByWorkflowId[task.workflowId ?? ""],
|
||||
});
|
||||
const choices = useTaskCommandChoices({
|
||||
task,
|
||||
workflows: data.workflows,
|
||||
stepsByWorkflowId: data.stepsByWorkflowId,
|
||||
linkHandlers,
|
||||
openMoveOptions: move.openMoveOptionsStep,
|
||||
moveImmediately: (step) => {
|
||||
void move.moveImmediately(step);
|
||||
},
|
||||
});
|
||||
const commands = useMemo(() => {
|
||||
const items = buildSidebarTaskCommands({
|
||||
task,
|
||||
t,
|
||||
...choices,
|
||||
isPinned: prefs.pinnedTaskIds.includes(task.id),
|
||||
disabled: actions.isDeleting,
|
||||
onPin: () => prefs.togglePinnedTask(task.id),
|
||||
onEdit: () => actions.handleEditTask(task),
|
||||
onRename: () => actions.handleRenameTask(task.id, task.title),
|
||||
onDetach: () => actions.handleDetachTask(task.id),
|
||||
onDelete: () => actions.handleDeleteTask(task.id),
|
||||
});
|
||||
return guardTaskCommands(items, () => {
|
||||
const state = store.getState();
|
||||
return state.tasks.activeTaskId === task.id && state.workspaces.activeId === workspaceId;
|
||||
});
|
||||
}, [task, t, choices, prefs, actions, store, workspaceId]);
|
||||
useRegisterCommands(commands);
|
||||
return (
|
||||
<>
|
||||
<SidebarDialogs
|
||||
actions={actions}
|
||||
repositories={repositories ?? []}
|
||||
workspaceId={workspaceId}
|
||||
stepsByWorkflowId={data.stepsByWorkflowId}
|
||||
/>
|
||||
<TaskMoveOptionsSurface
|
||||
step={move.moveOptionsStep}
|
||||
isMoving={move.isMoving}
|
||||
onClose={move.closeMoveOptions}
|
||||
autoFocusInstructions
|
||||
onSubmit={(options) => {
|
||||
const state = store.getState();
|
||||
if (
|
||||
state.tasks.activeTaskId !== task.id ||
|
||||
state.workspaces.activeId !== workspaceId ||
|
||||
task.isArchived
|
||||
)
|
||||
return false;
|
||||
return move.submitMoveOptions(options);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function guardTaskCommands(commands: CommandItem[], isCurrent: () => boolean): CommandItem[] {
|
||||
return commands.map((command) => ({
|
||||
...command,
|
||||
children: command.children ? guardTaskCommands(command.children, isCurrent) : undefined,
|
||||
immediateAction: command.immediateAction
|
||||
? () => {
|
||||
if (isCurrent() && !command.disabled) command.immediateAction?.();
|
||||
}
|
||||
: undefined,
|
||||
action: command.action
|
||||
? () => {
|
||||
if (isCurrent() && !command.disabled) command.action?.();
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
@@ -69,10 +69,11 @@ export function useTaskMoveOptions({
|
||||
const runMove = async (
|
||||
targetStepId: string,
|
||||
entryOptions: WorkflowMoveEntryOptions | undefined,
|
||||
targetWorkflowId = workflowId,
|
||||
) => {
|
||||
if (!workflowId) return false;
|
||||
if (!targetWorkflowId) return false;
|
||||
const result = await move(taskId, {
|
||||
workflow_id: workflowId,
|
||||
workflow_id: targetWorkflowId,
|
||||
workflow_step_id: targetStepId,
|
||||
position: 0,
|
||||
entry_options: entryOptions,
|
||||
@@ -92,7 +93,11 @@ export function useTaskMoveOptions({
|
||||
|
||||
const submitMoveOptions = async (entryOptions: WorkflowMoveEntryOptions | undefined) => {
|
||||
if (!moveOptionsStep) return false;
|
||||
const ok = await runMove(moveOptionsStep.id, entryOptions);
|
||||
const ok = await runMove(
|
||||
moveOptionsStep.id,
|
||||
entryOptions,
|
||||
moveOptionsStep.workflow_id ?? workflowId,
|
||||
);
|
||||
if (ok) setMoveOptionsStep(null);
|
||||
return ok;
|
||||
};
|
||||
@@ -111,6 +116,8 @@ export function useTaskMoveOptions({
|
||||
openMoveOptionsStep,
|
||||
submitMoveOptions,
|
||||
submitMoveOptionsForStep,
|
||||
moveImmediately: (step: TaskMoveStep) =>
|
||||
runMove(step.id, undefined, step.workflow_id ?? workflowId),
|
||||
closeMoveOptions: () => {
|
||||
setMoveOptionsStep(null);
|
||||
},
|
||||
@@ -122,7 +129,9 @@ export function TaskMoveOptionsSurface({
|
||||
isMoving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
autoFocusInstructions,
|
||||
}: {
|
||||
autoFocusInstructions?: boolean;
|
||||
step: TaskMoveStep | null;
|
||||
isMoving: boolean;
|
||||
onClose: () => void;
|
||||
@@ -138,6 +147,7 @@ export function TaskMoveOptionsSurface({
|
||||
targetStepName: step.title,
|
||||
isMoving,
|
||||
onSubmit,
|
||||
autoFocusInstructions,
|
||||
};
|
||||
const Options = usesTouchDrawer ? WorkflowMoveOptions : WorkflowMoveDialog;
|
||||
return <Options {...optionsProps} />;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { TaskMoveErrorBanner } from "@/components/task/task-move-error-banner";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import { TaskArchivedProvider } from "./task-archived-context";
|
||||
import { TaskCommands } from "@/components/task-commands";
|
||||
import { SessionCommands } from "@/components/session-commands";
|
||||
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
|
||||
import { useEmbeddedVscodeSupport } from "@/components/task/task-page-editor-capability";
|
||||
@@ -415,6 +416,7 @@ export function TaskPageInner(props: TaskPageInnerProps) {
|
||||
/>
|
||||
)}
|
||||
<TaskArchivedProvider value={archivedValue}>
|
||||
<TaskCommands />
|
||||
<TaskLaunchErrorProvider
|
||||
value={{
|
||||
taskId: task.id,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { useTaskMoveOptions } from "./task-move-context-menu";
|
||||
|
||||
const move = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/hooks/domains/kanban/use-workflow-move", () => ({
|
||||
useWorkflowMove: () => ({ move, isMoving: false }),
|
||||
}));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
beforeEach(() => {
|
||||
move.mockReset().mockResolvedValue({ disposition: "committed" });
|
||||
});
|
||||
|
||||
it("submits options to the chosen workflow instead of the task's current workflow", async () => {
|
||||
const { result } = renderHook(() => useTaskMoveOptions({ taskId: "task", workflowId: "source" }));
|
||||
act(() =>
|
||||
result.current.openMoveOptionsStep({ id: "review", title: "Review", workflow_id: "target" }),
|
||||
);
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await result.current.submitMoveOptions({ instructions: "Check keyboard flow" });
|
||||
});
|
||||
expect(move).toHaveBeenCalledWith("task", {
|
||||
workflow_id: "target",
|
||||
workflow_step_id: "review",
|
||||
position: 0,
|
||||
entry_options: { instructions: "Check keyboard flow" },
|
||||
});
|
||||
expect(result.current.moveOptionsStep).toBeNull();
|
||||
});
|
||||
|
||||
it("moves directly without opening options and retains options after a rejected move", async () => {
|
||||
const { result } = renderHook(() => useTaskMoveOptions({ taskId: "task", workflowId: "source" }));
|
||||
await act(async () => {
|
||||
await result.current.moveImmediately({ id: "review", title: "Review", workflow_id: "target" });
|
||||
});
|
||||
expect(move).toHaveBeenCalledWith(
|
||||
"task",
|
||||
expect.objectContaining({ workflow_id: "target", entry_options: undefined }),
|
||||
);
|
||||
expect(result.current.moveOptionsStep).toBeNull();
|
||||
move.mockResolvedValue({ disposition: "failed", error: new Error("Rejected") });
|
||||
act(() =>
|
||||
result.current.openMoveOptionsStep({ id: "review", title: "Review", workflow_id: "target" }),
|
||||
);
|
||||
await act(async () => {
|
||||
expect(await result.current.submitMoveOptions(undefined)).toBe(false);
|
||||
});
|
||||
expect(result.current.moveOptionsStep?.id).toBe("review");
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
/** Shared single-task eligibility for sidebar and palette entry points. */
|
||||
export function taskRowActionAvailability(task: TaskSwitcherItem) {
|
||||
return {
|
||||
mark: !task.isArchived,
|
||||
edit: !task.isArchived && Boolean(task.workflowId && task.workflowStepId),
|
||||
nest: !task.isArchived && Boolean(task.workflowId),
|
||||
move: !task.isArchived && Boolean(task.workflowId),
|
||||
detach: Boolean(task.parentTaskId),
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ function findSidebarTask(state: ReturnType<StoreApi["getState"]>, taskId: string
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function useSidebarData(workspaceId: string | null) {
|
||||
export function useSidebarData(workspaceId: string | null) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionsById = useAppStore((state) => state.taskSessions.items);
|
||||
@@ -355,7 +355,6 @@ export function useSidebarActions(store: StoreApi) {
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
|
||||
const { renameTaskById } = useTaskActions();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { loadTaskSessionsForTask } = useTaskRemoval({
|
||||
@@ -373,6 +372,11 @@ export function useSidebarActions(store: StoreApi) {
|
||||
setPreparingTaskId,
|
||||
});
|
||||
|
||||
return { preparingTaskId, handleSelectTask, ...useTaskRowActions(store) };
|
||||
}
|
||||
|
||||
export function useTaskRowActions(store: StoreApi) {
|
||||
const { renameTaskById } = useTaskActions();
|
||||
const archiveActions = useArchiveActions(store);
|
||||
const deleteActions = useDeleteActions(store);
|
||||
const detachActions = useTaskDetachDialog(store);
|
||||
@@ -416,9 +420,7 @@ export function useSidebarActions(store: StoreApi) {
|
||||
const handleMoveToStep = useMoveToStep(store, clearTaskMoveError, reportTaskMoveError);
|
||||
|
||||
return {
|
||||
preparingTaskId,
|
||||
taskMoveError,
|
||||
handleSelectTask,
|
||||
handleMoveToStep,
|
||||
handleNestTask,
|
||||
renamingTask,
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { StepDef, TaskSwitcherItem } from "./task-switcher-types";
|
||||
import { useTaskPluginPrimaryMenuEntries } from "./task-switcher-plugin-menu-items";
|
||||
import { TaskPriorityContextMenu } from "./task-priority-context-menu";
|
||||
import type { TaskContextMenuItemsProps } from "./task-switcher-context-menu";
|
||||
import { taskRowActionAvailability } from "./task-row-action-availability";
|
||||
import { TaskMoveItems } from "./task-switcher-context-menu-move-items";
|
||||
|
||||
type SingleSelectionMenuProps = TaskContextMenuItemsProps & {
|
||||
@@ -202,7 +203,7 @@ function hasSingleMoveGroup({
|
||||
workflowId: string | undefined;
|
||||
currentMoveSteps: StepDef[];
|
||||
}) {
|
||||
if (task.isArchived || !workflowId) return false;
|
||||
if (!taskRowActionAvailability(task).move) return false;
|
||||
const hasSameWorkflowMove =
|
||||
currentMoveSteps.length > 1 && (!actingOnSelection || !isMixedWorkflowSelection);
|
||||
const hasCrossWorkflowMove = (workflows ?? []).some(
|
||||
@@ -243,14 +244,14 @@ function SingleMarkGroup({
|
||||
disabled={isDeleting}
|
||||
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
|
||||
/>
|
||||
{!task.isArchived && (
|
||||
{taskRowActionAvailability(task).mark && (
|
||||
<TaskColorMenu
|
||||
taskId={task.id}
|
||||
disabled={isDeleting}
|
||||
automaticColorSource={task.automaticColorSource}
|
||||
/>
|
||||
)}
|
||||
{!task.isArchived && (
|
||||
{taskRowActionAvailability(task).mark && (
|
||||
<TaskPriorityContextMenu
|
||||
currentPriority={task.priority}
|
||||
disabled={isDeleting}
|
||||
@@ -272,7 +273,7 @@ function SingleEditGroup({
|
||||
<>
|
||||
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
|
||||
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
|
||||
{!task.isArchived && (
|
||||
{taskRowActionAvailability(task).mark && (
|
||||
<ContextMenuItem disabled>
|
||||
<IconCopy className="mr-2 h-4 w-4" />
|
||||
{t("settings:duplicate")}
|
||||
@@ -569,7 +570,7 @@ function TaskEditItem({
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null;
|
||||
if (!onEditTask || !taskRowActionAvailability(task).edit) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
|
||||
<IconEdit className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { formatShortcut, matchesShortcut } from "@/lib/keyboard/utils";
|
||||
|
||||
const MOVE_SHORTCUT = { key: "Enter", modifiers: { ctrlOrCmd: true } } as const;
|
||||
|
||||
export function workflowMoveShortcutLabel() {
|
||||
return formatShortcut(MOVE_SHORTCUT);
|
||||
}
|
||||
|
||||
/** A local submission boundary shared by the Move button and its options fields. */
|
||||
export function useWorkflowMoveSubmit(isMoving: boolean, onSubmit: () => Promise<unknown>) {
|
||||
const inFlight = useRef(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const busy = isMoving || submitting;
|
||||
const submit = async () => {
|
||||
if (isMoving || inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.repeat ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.keyCode === 229 ||
|
||||
!matchesShortcut(event, MOVE_SHORTCUT)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void submit();
|
||||
};
|
||||
return { busy, submit, onKeyDown };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
|
||||
import { useWorkflowMoveSubmit, workflowMoveShortcutLabel } from "./use-workflow-move-submit";
|
||||
import {
|
||||
WorkflowMovePreviewFooter,
|
||||
type WorkflowMovePreviewTarget,
|
||||
@@ -76,6 +77,7 @@ type WorkflowMoveOptionsFieldsProps = {
|
||||
onDraftChange: (patch: Partial<WorkflowMoveOptionsDraft>) => void;
|
||||
isTouchSurface: boolean;
|
||||
instructionsRows?: number;
|
||||
autoFocusInstructions?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -87,6 +89,7 @@ export function WorkflowMoveOptionsFields({
|
||||
onDraftChange,
|
||||
isTouchSurface,
|
||||
instructionsRows = 4,
|
||||
autoFocusInstructions,
|
||||
}: WorkflowMoveOptionsFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const resetContextId = useId();
|
||||
@@ -145,6 +148,7 @@ export function WorkflowMoveOptionsFields({
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-medium">{t("task:workflowMoveInstructions")}</span>
|
||||
<Textarea
|
||||
autoFocus={autoFocusInstructions}
|
||||
value={draft.instructions}
|
||||
onChange={(event) => onDraftChange({ instructions: event.target.value })}
|
||||
placeholder={t("task:workflowMoveInstructionsPlaceholder")}
|
||||
@@ -166,6 +170,7 @@ type WorkflowMoveOptionsFormProps = {
|
||||
isMoving: boolean;
|
||||
isTouchSurface: boolean;
|
||||
instructionsRows?: number;
|
||||
autoFocusInstructions?: boolean;
|
||||
onSubmit: WorkflowMoveOptionsSubmit;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
@@ -177,26 +182,11 @@ type WorkflowMoveOptionsStateProps = {
|
||||
|
||||
function useWorkflowMoveOptionsFormState({ onSubmit, isMoving }: WorkflowMoveOptionsStateProps) {
|
||||
const { draft, patchDraft, resetDraft } = useWorkflowMoveOptionsForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const busy = isMoving || submitting;
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onSubmit(workflowMoveOptionsPayload(draft));
|
||||
if (result !== false) resetDraft();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
draft,
|
||||
patchDraft,
|
||||
busy,
|
||||
submit,
|
||||
};
|
||||
const submission = useWorkflowMoveSubmit(isMoving, async () => {
|
||||
const result = await onSubmit(workflowMoveOptionsPayload(draft));
|
||||
if (result !== false) resetDraft();
|
||||
});
|
||||
return { draft, patchDraft, ...submission };
|
||||
}
|
||||
|
||||
function WorkflowMoveOptionsActions({
|
||||
@@ -234,6 +224,11 @@ function WorkflowMoveOptionsActions({
|
||||
data-testid="workflow-move-submit"
|
||||
>
|
||||
{busy ? t("task:moving") : t("task:workflowMoveApply")}
|
||||
{!isTouchSurface && (
|
||||
<kbd className="ml-1 self-center font-sans text-[10px] leading-none opacity-60">
|
||||
{workflowMoveShortcutLabel()}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -249,20 +244,22 @@ export function WorkflowMoveOptionsForm({
|
||||
isMoving,
|
||||
isTouchSurface,
|
||||
instructionsRows,
|
||||
autoFocusInstructions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: WorkflowMoveOptionsFormProps) {
|
||||
const { draft, patchDraft, busy, submit } = useWorkflowMoveOptionsFormState({
|
||||
const { draft, patchDraft, busy, submit, onKeyDown } = useWorkflowMoveOptionsFormState({
|
||||
onSubmit,
|
||||
isMoving,
|
||||
});
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4" onKeyDown={onKeyDown}>
|
||||
<WorkflowMoveOptionsFields
|
||||
draft={draft}
|
||||
onDraftChange={patchDraft}
|
||||
isTouchSurface={isTouchSurface}
|
||||
instructionsRows={instructionsRows}
|
||||
autoFocusInstructions={autoFocusInstructions}
|
||||
/>
|
||||
<WorkflowMoveOptionsActions
|
||||
busy={busy}
|
||||
@@ -286,6 +283,7 @@ type WorkflowMoveOptionsProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetStepName: string;
|
||||
autoFocusInstructions?: boolean;
|
||||
isMoving?: boolean;
|
||||
onSubmit: WorkflowMoveOptionsSubmit;
|
||||
};
|
||||
@@ -296,6 +294,7 @@ export function WorkflowMoveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetStepName,
|
||||
autoFocusInstructions,
|
||||
isMoving = false,
|
||||
onSubmit,
|
||||
}: WorkflowMoveOptionsProps) {
|
||||
@@ -308,6 +307,7 @@ export function WorkflowMoveDialog({
|
||||
<DialogDescription>{t("task:workflowMoveOptionsDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<WorkflowMoveOptionsForm
|
||||
autoFocusInstructions={autoFocusInstructions}
|
||||
previewTarget={previewTarget}
|
||||
isMoving={isMoving}
|
||||
isTouchSurface={false}
|
||||
@@ -325,6 +325,7 @@ export function WorkflowMoveOptions({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetStepName,
|
||||
autoFocusInstructions,
|
||||
isMoving = false,
|
||||
onSubmit,
|
||||
}: WorkflowMoveOptionsProps) {
|
||||
@@ -344,6 +345,7 @@ export function WorkflowMoveOptions({
|
||||
data-testid="workflow-move-options"
|
||||
>
|
||||
<WorkflowMoveOptionsForm
|
||||
autoFocusInstructions={autoFocusInstructions}
|
||||
previewTarget={previewTarget}
|
||||
isMoving={isMoving}
|
||||
isTouchSurface
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { WorkflowMoveOptionsForm } from "./workflow-move-options";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function setup(onSubmit = vi.fn().mockResolvedValue(false), isMoving = false) {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<WorkflowMoveOptionsForm isMoving={isMoving} isTouchSurface={false} onSubmit={onSubmit} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
const input = screen.getByTestId("workflow-move-instructions");
|
||||
fireEvent.change(input, { target: { value: " Check timeout\nKeep the context " } });
|
||||
return { input, onSubmit };
|
||||
}
|
||||
|
||||
describe("move keyboard submission", () => {
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-001.1
|
||||
it.each(["metaKey", "ctrlKey"])("submits the full draft with %s+Enter", async (modifier) => {
|
||||
const { input, onSubmit } = setup();
|
||||
fireEvent.click(screen.getByTestId("workflow-move-reset-context"));
|
||||
fireEvent.click(screen.getByTestId("workflow-move-skip-step-prompt"));
|
||||
fireEvent.keyDown(input, { key: "Enter", [modifier]: true });
|
||||
await act(async () => {});
|
||||
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
|
||||
instructions: "Check timeout\nKeep the context",
|
||||
reset_context: true,
|
||||
skip_step_prompt: true,
|
||||
});
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-001.2
|
||||
it.each([
|
||||
{},
|
||||
{ shiftKey: true },
|
||||
{ metaKey: true, shiftKey: true },
|
||||
{ ctrlKey: true, altKey: true },
|
||||
{ metaKey: true, repeat: true },
|
||||
{ metaKey: true, isComposing: true },
|
||||
{ ctrlKey: true, keyCode: 229 },
|
||||
])("leaves editing and composition alone: %j", (modifiers) => {
|
||||
const { input, onSubmit } = setup();
|
||||
fireEvent.keyDown(input, { key: "Enter", ...modifiers });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-001.3
|
||||
it("does not submit a disabled move", () => {
|
||||
const { input, onSubmit } = setup(vi.fn(), true);
|
||||
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes shortcut and click submissions and preserves a failed draft", async () => {
|
||||
let finish!: (value: boolean) => void;
|
||||
const onSubmit = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { input } = setup(onSubmit);
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
|
||||
fireEvent.click(screen.getByTestId("workflow-move-submit"));
|
||||
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
||||
});
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish(false));
|
||||
expect((input as HTMLTextAreaElement).value).toBe(" Check timeout\nKeep the context ");
|
||||
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||
await act(async () => finish(true));
|
||||
expect((input as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { IconChevronDown, IconArrowRight } from "@tabler/icons-react";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
import { workflowMoveOptionsPayload, type WorkflowMoveOptionsDraft } from "./workflow-move-options";
|
||||
import { workflowMoveShortcutLabel } from "./use-workflow-move-submit";
|
||||
|
||||
type StepDisclosureRowActionsProps = {
|
||||
stepId: string;
|
||||
@@ -13,10 +12,8 @@ type StepDisclosureRowActionsProps = {
|
||||
movePending: boolean;
|
||||
showOptions: boolean;
|
||||
buttonSizeClass: string;
|
||||
draft: WorkflowMoveOptionsDraft;
|
||||
entryOptions?: WorkflowMoveEntryOptions;
|
||||
onToggleOptions: () => void;
|
||||
onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise<boolean>;
|
||||
onSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function StepDisclosureRowActions({
|
||||
@@ -25,10 +22,8 @@ export function StepDisclosureRowActions({
|
||||
movePending,
|
||||
showOptions,
|
||||
buttonSizeClass,
|
||||
draft,
|
||||
entryOptions,
|
||||
onToggleOptions,
|
||||
onMove,
|
||||
onSubmit,
|
||||
}: StepDisclosureRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -41,10 +36,15 @@ export function StepDisclosureRowActions({
|
||||
variant="outline"
|
||||
className={cn("shrink-0 cursor-pointer rounded-sm px-2.5 text-xs", buttonSizeClass)}
|
||||
disabled={movePending}
|
||||
onClick={() => void onMove(stepId, entryOptions ?? workflowMoveOptionsPayload(draft))}
|
||||
onClick={() => void onSubmit()}
|
||||
>
|
||||
{isMoving ? t("task:moving") : t("task:moveHere")}
|
||||
<IconArrowRight className="h-3 w-3" />
|
||||
{showOptions && (
|
||||
<kbd className="hidden self-center font-sans text-[10px] leading-none opacity-60 [@media(pointer:fine)]:inline">
|
||||
{workflowMoveShortcutLabel()}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useWorkflowMoveSubmit } from "./use-workflow-move-submit";
|
||||
import { StepDisclosureRowActions } from "./workflow-step-disclosure-actions";
|
||||
import {
|
||||
WorkflowMoveOptionsFields,
|
||||
@@ -38,6 +39,7 @@ export function StepDisclosureMoveControls({
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const { draft, patchDraft } = useWorkflowMoveOptionsForm();
|
||||
const entryOptions = workflowMoveOptionsPayload(draft);
|
||||
const submission = useWorkflowMoveSubmit(movePending, () => onMove(stepId, entryOptions));
|
||||
const invalidationKey = useWorkflowMovePreviewRevision(taskId, workflowId, stepId);
|
||||
const previewState = useWorkflowMovePreview({
|
||||
taskId,
|
||||
@@ -56,13 +58,11 @@ export function StepDisclosureMoveControls({
|
||||
<StepDisclosureRowActions
|
||||
stepId={stepId}
|
||||
isMoving={isMoving}
|
||||
movePending={movePending}
|
||||
movePending={submission.busy}
|
||||
showOptions={showOptions}
|
||||
buttonSizeClass={buttonSizeClass}
|
||||
draft={draft}
|
||||
entryOptions={entryOptions}
|
||||
onToggleOptions={() => setShowOptions((value) => !value)}
|
||||
onMove={onMove}
|
||||
onSubmit={submission.submit}
|
||||
/>
|
||||
</div>
|
||||
<CompactWorkflowMovePreview
|
||||
@@ -73,7 +73,10 @@ export function StepDisclosureMoveControls({
|
||||
{showOptions && (
|
||||
<div
|
||||
className="pb-1 pl-4 pr-1"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
submission.onKeyDown(event);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
data-testid={`workflow-step-disclosure-options-panel-${stepId}`}
|
||||
>
|
||||
<WorkflowMoveOptionsFields
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MinimalWorkflowStepper } from "./workflow-step-disclosure";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { moveTask } from "@/lib/api";
|
||||
@@ -50,15 +52,6 @@ vi.mock("@/lib/state/dockview-store", () => ({
|
||||
vi.mock("@/hooks/use-toolbar-collapsed", () => ({
|
||||
useToolbarCollapsed: () => false,
|
||||
}));
|
||||
vi.mock("./workflow-move-options", () => ({
|
||||
useWorkflowMoveOptionsForm: () => ({
|
||||
draft: {},
|
||||
patchDraft: vi.fn(),
|
||||
resetDraft: vi.fn(),
|
||||
}),
|
||||
WorkflowMoveOptionsFields: () => null,
|
||||
workflowMoveOptionsPayload: () => undefined,
|
||||
}));
|
||||
|
||||
const POPOVER_TEST_ID = "workflow-step-popover";
|
||||
const REVIEW_TRIGGER_TEST_ID = "workflow-step-Review";
|
||||
@@ -152,6 +145,55 @@ describe("WorkflowStepper full-layout keyboard disclosure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-001.1
|
||||
it("submits inline step options from the instruction field", async () => {
|
||||
moveTaskMock.mockResolvedValue({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<WorkflowStepper steps={STEPS} currentStepId="work" taskId="task-1" workflowId="workflow-1" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
screen.getByTestId("workflow-step-Review").focus();
|
||||
fireEvent.click(await screen.findByTestId("workflow-step-move-options-trigger"));
|
||||
const input = screen.getByTestId("workflow-move-instructions");
|
||||
fireEvent.change(input, { target: { value: "Review keyboard flow" } });
|
||||
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
|
||||
await waitFor(() =>
|
||||
expect(moveTask).toHaveBeenCalledWith("task-1", {
|
||||
workflow_id: "workflow-1",
|
||||
workflow_step_id: "review",
|
||||
position: 0,
|
||||
entry_options: { instructions: "Review keyboard flow" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("submits compact disclosure options from the instruction field", async () => {
|
||||
const onMove = vi.fn().mockResolvedValue(false);
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MinimalWorkflowStepper
|
||||
sortedSteps={STEPS}
|
||||
currentIndex={1}
|
||||
taskId="task-1"
|
||||
workflowId="workflow-1"
|
||||
movingToStepId={null}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("workflow-stepper-minimal"));
|
||||
fireEvent.click(await screen.findByTestId("workflow-step-disclosure-options-review"));
|
||||
const input = screen.getByTestId("workflow-move-instructions");
|
||||
fireEvent.change(input, { target: { value: "Compact instructions" } });
|
||||
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
||||
await waitFor(() =>
|
||||
expect(onMove).toHaveBeenCalledWith("review", {
|
||||
instructions: "Compact instructions",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("WorkflowStepper exclusive hover", () => {
|
||||
it("replaces a focused step hover when the pointer enters another step", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useWorkflowMoveSubmit, workflowMoveShortcutLabel } from "./use-workflow-move-submit";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
type Dispatch,
|
||||
@@ -413,6 +415,7 @@ function StepMoveControls({
|
||||
enabled: previewEnabled,
|
||||
invalidationKey,
|
||||
});
|
||||
const submission = useWorkflowMoveSubmit(isMoving, () => onMove(step.id, entryOptions));
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch gap-1.5">
|
||||
@@ -420,17 +423,25 @@ function StepMoveControls({
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="cursor-pointer text-xs h-6 px-2.5 rounded-sm"
|
||||
disabled={isMoving}
|
||||
onClick={() => void onMove(step.id, entryOptions)}
|
||||
disabled={submission.busy}
|
||||
onClick={() => void submission.submit()}
|
||||
data-testid="workflow-step-move-here"
|
||||
>
|
||||
<IconArrowRight className="h-3 w-3" />
|
||||
{isMoving ? t("task:moving") : t("task:moveHere")}
|
||||
{showOptions && (
|
||||
<kbd className="self-center font-sans text-[10px] leading-none opacity-60">
|
||||
{workflowMoveShortcutLabel()}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
{showOptions ? (
|
||||
<div
|
||||
className="w-64 max-w-[calc(100vw-2rem)]"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
submission.onKeyDown(event);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<WorkflowMoveOptionsFields
|
||||
draft={draft}
|
||||
|
||||
@@ -58,6 +58,7 @@ test.describe("Command palette archive", () => {
|
||||
await testPage.keyboard.press("Enter");
|
||||
|
||||
// The shared confirmation dialog names the task being archived.
|
||||
await expect(testPage.getByRole("combobox")).toHaveCount(0);
|
||||
const confirm = testPage.getByRole("alertdialog");
|
||||
await expect(confirm.getByText(/Palette Archive Target/)).toBeVisible({ timeout: 10_000 });
|
||||
await testPage.getByTestId("palette-archive-confirm").click();
|
||||
@@ -105,6 +106,13 @@ test.describe("Command palette archive", () => {
|
||||
.filter({ has: testPage.getByText("Create Subtask", { exact: true }) }),
|
||||
).toBeVisible();
|
||||
|
||||
for (const label of ["Rename", "Link", "Delete"]) {
|
||||
await dialog.getByRole("combobox").fill(label);
|
||||
await expect(
|
||||
dialog.getByRole("option").filter({ has: testPage.getByText(label, { exact: true }) }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole("combobox").fill("archive");
|
||||
await expect(
|
||||
dialog
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
seedMoveOverrideFixture,
|
||||
waitForMoveRequest,
|
||||
expectMoveInstructionsDelivered,
|
||||
MOVE_INSTRUCTIONS,
|
||||
} from "../workflow/workflow-step-move-overrides-helpers";
|
||||
|
||||
async function chooseCommand(page: Page, label: string) {
|
||||
await page.keyboard.press("Control+k");
|
||||
const palette = page.getByRole("dialog").filter({ has: page.getByRole("combobox") });
|
||||
await palette.getByRole("combobox").fill(label);
|
||||
const command = palette
|
||||
.getByRole("option")
|
||||
.filter({ has: page.getByText(label, { exact: true }) });
|
||||
await expect(command).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-002.1, AC-TASKS-KEYBOARD-ACTIONS-002.2, AC-TASKS-KEYBOARD-ACTIONS-001.3
|
||||
test("moves the current task from the keyboard and retains failed instructions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Keyboard palette move",
|
||||
);
|
||||
await chooseCommand(testPage, "Move to");
|
||||
const palette = testPage.getByRole("dialog").filter({ has: testPage.getByRole("combobox") });
|
||||
await palette.getByRole("combobox").fill("Verify");
|
||||
await expect(
|
||||
palette.getByRole("option").filter({ has: testPage.getByText("Verify", { exact: true }) }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await testPage.keyboard.press("Enter");
|
||||
const input = testPage.getByTestId("workflow-move-instructions");
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill(MOVE_INSTRUCTIONS);
|
||||
await testPage.screenshot({ path: "test-results/desktop-task-command-move.png" });
|
||||
const endpoint = `**/api/v1/tasks/${fixture.taskId}/move`;
|
||||
await testPage.route(
|
||||
endpoint,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Move rejected for retry coverage" }),
|
||||
}),
|
||||
{ times: 1 },
|
||||
);
|
||||
const failed = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/tasks/${fixture.taskId}/move`) && response.status() === 400,
|
||||
);
|
||||
await input.press("Control+Enter");
|
||||
await failed;
|
||||
await expect(input).toHaveValue(MOVE_INSTRUCTIONS);
|
||||
await expect(testPage.getByTestId("workflow-move-submit")).toBeEnabled();
|
||||
expect((await apiClient.getTask(fixture.taskId)).workflow_step_id).toBe(fixture.sourceStepId);
|
||||
const request = waitForMoveRequest(testPage, fixture.taskId);
|
||||
await input.press("Control+Enter");
|
||||
expect((await request).postDataJSON()).toMatchObject({
|
||||
workflow_step_id: fixture.targetStepId,
|
||||
entry_options: { instructions: MOVE_INSTRUCTIONS },
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(fixture.taskId)).workflow_step_id)
|
||||
.toBe(fixture.targetStepId);
|
||||
await expectMoveInstructionsDelivered(fixture.session);
|
||||
const { messages } = await apiClient.listSessionMessages(fixture.primarySessionId);
|
||||
expect(
|
||||
messages.filter(
|
||||
(message) => message.author_type === "user" && message.content.includes(MOVE_INSTRUCTIONS),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-002.3, AC-TASKS-KEYBOARD-ACTIONS-002.4
|
||||
test("uses sidebar metadata actions and preserves destructive confirmation", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Keyboard palette actions",
|
||||
);
|
||||
await chooseCommand(testPage, "Pin");
|
||||
await chooseCommand(testPage, "Unpin");
|
||||
await chooseCommand(testPage, "Rename");
|
||||
const rename = testPage.getByRole("dialog", { name: "Rename task" });
|
||||
await rename.getByRole("textbox").fill("Renamed by keyboard");
|
||||
await rename.getByRole("textbox").press("Enter");
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(fixture.taskId)).title)
|
||||
.toBe("Renamed by keyboard");
|
||||
await chooseCommand(testPage, "Priority");
|
||||
const palette = testPage.getByRole("dialog").filter({ has: testPage.getByRole("combobox") });
|
||||
await palette.getByRole("combobox").fill("High");
|
||||
await testPage.keyboard.press("Enter");
|
||||
await expect.poll(async () => (await apiClient.getTask(fixture.taskId)).priority).toBe("high");
|
||||
await chooseCommand(testPage, "Delete");
|
||||
const confirm = testPage.getByRole("alertdialog");
|
||||
await expect(confirm).toContainText("Renamed by keyboard");
|
||||
await confirm.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
expect((await apiClient.getTask(fixture.taskId)).title).toBe("Renamed by keyboard");
|
||||
});
|
||||
|
||||
test("shows destination colors and moves immediately with the modified Enter shortcut", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Immediate keyboard move",
|
||||
);
|
||||
await chooseCommand(testPage, "Color");
|
||||
const red = testPage.getByRole("option", { name: "Red", exact: true });
|
||||
await expect(red.locator(".bg-red-500")).toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await chooseCommand(testPage, "Move to");
|
||||
const target = testPage
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Verify", { exact: true }) });
|
||||
await expect(target.locator(".rounded-full")).toBeVisible();
|
||||
await expect(target.locator(".tabler-icon-arrow-right")).toBeVisible();
|
||||
await testPage.getByRole("combobox").fill("Verify");
|
||||
const request = waitForMoveRequest(testPage, fixture.taskId);
|
||||
await testPage.keyboard.press("Control+Enter");
|
||||
expect((await request).postDataJSON()).toMatchObject({ workflow_step_id: fixture.targetStepId });
|
||||
await expect(testPage.getByTestId("workflow-move-instructions")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(fixture.taskId)).workflow_step_id)
|
||||
.toBe(fixture.targetStepId);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import {
|
||||
seedMoveOverrideFixture,
|
||||
waitForMoveRequest,
|
||||
MOVE_INSTRUCTIONS,
|
||||
} from "../workflow/workflow-step-move-overrides-helpers";
|
||||
|
||||
// @covers AC-TASKS-KEYBOARD-ACTIONS-002.5
|
||||
test("uses nested task commands and the move drawer on a phone", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Mobile palette actions",
|
||||
);
|
||||
await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible();
|
||||
await testPage.keyboard.press("Control+k");
|
||||
const palette = testPage.getByRole("dialog").filter({ has: testPage.getByRole("combobox") });
|
||||
const search = palette.getByRole("combobox");
|
||||
await search.fill("Move to");
|
||||
const move = palette
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Move to", { exact: true }) });
|
||||
await move.tap();
|
||||
const back = palette.getByRole("button", { name: "Back", exact: true });
|
||||
await back.tap();
|
||||
await expect(search).toHaveValue("Move to");
|
||||
await move.tap();
|
||||
const target = palette
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Verify", { exact: true }) });
|
||||
expect((await target.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await target.tap();
|
||||
await expect(palette).toBeHidden();
|
||||
const drawer = testPage.locator('[data-slot="drawer-content"]:visible');
|
||||
await expect(drawer).toBeVisible();
|
||||
await testPage.getByTestId("workflow-move-instructions").fill(MOVE_INSTRUCTIONS);
|
||||
const submit = testPage.getByTestId("workflow-move-submit");
|
||||
await submit.scrollIntoViewIfNeeded();
|
||||
expect((await submit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
await testPage.screenshot({ path: "test-results/mobile-task-command-move.png" });
|
||||
const request = waitForMoveRequest(testPage, fixture.taskId);
|
||||
await submit.tap();
|
||||
expect((await request).postDataJSON()).toMatchObject({
|
||||
entry_options: { instructions: MOVE_INSTRUCTIONS },
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(fixture.taskId)).workflow_step_id)
|
||||
.toBe(fixture.targetStepId);
|
||||
await testPage.keyboard.press("Control+k");
|
||||
await testPage.getByRole("combobox").fill("Archive task");
|
||||
await testPage
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Archive task", { exact: true }) })
|
||||
.tap();
|
||||
const confirm = testPage.getByRole("dialog", { name: "Archive task?", exact: true });
|
||||
await expect(confirm).toBeVisible();
|
||||
await expect(testPage.getByRole("combobox")).toHaveCount(0);
|
||||
const cancel = confirm.getByRole("button", { name: "Cancel", exact: true });
|
||||
expect((await cancel.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
await testPage.screenshot({ path: "test-results/mobile-task-command-archive.png" });
|
||||
await cancel.tap();
|
||||
});
|
||||
@@ -86,7 +86,7 @@ test("short-taps the existing mobile next-step button for a direct move", async
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("long-presses the existing mobile next-step button for move options", async ({
|
||||
test("submits mobile move options with an attached keyboard", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -109,7 +109,7 @@ test("long-presses the existing mobile next-step button for move options", async
|
||||
);
|
||||
await fillMoveOverrides(testPage);
|
||||
const moveRequest = waitForMoveRequest(testPage, fixture.taskId);
|
||||
await testPage.getByTestId("workflow-move-submit").tap();
|
||||
await testPage.getByTestId("workflow-move-instructions").press("Meta+Enter");
|
||||
|
||||
expect((await moveRequest).postDataJSON()).toEqual({
|
||||
workflow_id: expect.any(String),
|
||||
|
||||
@@ -63,7 +63,7 @@ async function openStepperMoveOptions(testPage: Parameters<typeof waitForMoveReq
|
||||
await expect(testPage.getByTestId("workflow-move-instructions")).toBeVisible();
|
||||
}
|
||||
|
||||
test("moves in place with one-shot options from the desktop stepper", async ({
|
||||
test("moves with Ctrl+Enter and one-shot options from the desktop stepper", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -82,7 +82,7 @@ test("moves in place with one-shot options from the desktop stepper", async ({
|
||||
await openStepperMoveOptions(testPage);
|
||||
await fillMoveOverrides(testPage);
|
||||
const moveRequest = waitForMoveRequest(testPage, fixture.taskId);
|
||||
await testPage.getByTestId("workflow-step-move-here").click();
|
||||
await testPage.getByTestId("workflow-move-instructions").press("Control+Enter");
|
||||
|
||||
expect((await moveRequest).postDataJSON()).toEqual({
|
||||
workflow_id: expect.any(String),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CommandItem, CommandPanelMode } from "@/lib/commands/types";
|
||||
|
||||
type Frame = { id: string; search: string; selection: string };
|
||||
type Options = {
|
||||
commands: CommandItem[];
|
||||
open: boolean;
|
||||
contextKey: string;
|
||||
mode: CommandPanelMode;
|
||||
setMode: (mode: CommandPanelMode) => void;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
selection: string;
|
||||
setSelection: (selection: string) => void;
|
||||
};
|
||||
|
||||
export function useCommandChildren(options: Options) {
|
||||
const { commands, open, contextKey, mode, setMode, search, setSearch, selection, setSelection } =
|
||||
options;
|
||||
const [navigation, setNavigation] = useState<{ contextKey: string; frames: Frame[] }>({
|
||||
contextKey,
|
||||
frames: [],
|
||||
});
|
||||
const frames = navigation.contextKey === contextKey ? navigation.frames : [];
|
||||
let items = commands;
|
||||
let parent: CommandItem | undefined;
|
||||
for (const frame of frames) {
|
||||
parent = items.find((item) => item.id === frame.id && !item.disabled);
|
||||
items = parent?.children ?? [];
|
||||
}
|
||||
const nested = mode === "command-children";
|
||||
useEffect(() => {
|
||||
if (open && navigation.contextKey === contextKey && (!nested || parent?.children)) return;
|
||||
queueMicrotask(() => {
|
||||
setNavigation({ contextKey, frames: [] });
|
||||
if (nested) {
|
||||
setMode("commands");
|
||||
setSearch("");
|
||||
setSelection("");
|
||||
}
|
||||
});
|
||||
}, [
|
||||
open,
|
||||
contextKey,
|
||||
navigation.contextKey,
|
||||
nested,
|
||||
parent?.children,
|
||||
setMode,
|
||||
setSearch,
|
||||
setSelection,
|
||||
]);
|
||||
|
||||
const enter = (command: CommandItem) => {
|
||||
setNavigation({
|
||||
contextKey,
|
||||
frames: [...(nested ? frames : []), { id: command.id, search, selection }],
|
||||
});
|
||||
setSearch("");
|
||||
setSelection(command.children?.find((item) => !item.disabled)?.id ?? "");
|
||||
setMode("command-children");
|
||||
};
|
||||
const back = () => {
|
||||
const frame = frames.at(-1);
|
||||
setNavigation({ contextKey, frames: frames.slice(0, -1) });
|
||||
setSearch(frame?.search ?? "");
|
||||
setSelection(frame?.selection ?? "");
|
||||
setMode(frames.length > 1 ? "command-children" : "commands");
|
||||
};
|
||||
return { commands: nested ? items : commands, parent: nested ? parent : undefined, enter, back };
|
||||
}
|
||||
@@ -190,3 +190,37 @@ describe("selectContentSearchResult", () => {
|
||||
expect(selectContentSearchResult([], "anything")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for idle task results instead of preselecting a task command", () => {
|
||||
const commands = [command("pin", "Pin task")];
|
||||
const loadingSelection = selectCommandSearchResult({
|
||||
commands,
|
||||
search: "",
|
||||
taskResultValues: [],
|
||||
commandsLeadResults: false,
|
||||
});
|
||||
expect(loadingSelection).toBe("");
|
||||
expect(
|
||||
selectCommandSearchResult({
|
||||
commands,
|
||||
search: "",
|
||||
taskResultValues: ["__task:task-1 Active task"],
|
||||
preferredValue: loadingSelection,
|
||||
commandsLeadResults: false,
|
||||
}),
|
||||
).toBe("__task:task-1 Active task");
|
||||
});
|
||||
|
||||
it("preselects the first enabled child when a submenu has no query", () => {
|
||||
expect(
|
||||
selectCommandSearchResult({
|
||||
commands: [
|
||||
{ ...command("disabled", "Disabled"), disabled: true },
|
||||
command("review", "Review"),
|
||||
],
|
||||
search: "",
|
||||
taskResultValues: [],
|
||||
commandsLeadResults: true,
|
||||
}),
|
||||
).toBe("review");
|
||||
});
|
||||
|
||||
@@ -59,13 +59,13 @@ export function findFirstMatchingCommand(
|
||||
preferredCommandId?: string,
|
||||
): CommandItem | undefined {
|
||||
const preferredCommand = preferredCommandId
|
||||
? commands.find((command) => command.id === preferredCommandId)
|
||||
? commands.find((command) => command.id === preferredCommandId && !command.disabled)
|
||||
: undefined;
|
||||
if (preferredCommand && commandSearchScore(preferredCommand, search) > 0) {
|
||||
return preferredCommand;
|
||||
}
|
||||
return sortCommandsForSearch(commands, search).find(
|
||||
(command) => commandSearchScore(command, search) > 0,
|
||||
(command) => !command.disabled && commandSearchScore(command, search) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,18 +106,21 @@ export function selectCommandSearchResult(options: CommandSearchSelectionOptions
|
||||
const normalizedSearch = search.trim();
|
||||
if (preferredValue) {
|
||||
if (taskResultValues.includes(preferredValue)) return preferredValue;
|
||||
const preferredCommand = commands.find((command) => command.id === preferredValue);
|
||||
const preferredCommandStillVisible =
|
||||
preferredCommand &&
|
||||
(!normalizedSearch ||
|
||||
findFirstMatchingCommand(commands, normalizedSearch, preferredValue)?.id ===
|
||||
preferredValue);
|
||||
if (preferredCommandStillVisible) return preferredValue;
|
||||
if (isVisibleCommand(commands, preferredValue, normalizedSearch)) return preferredValue;
|
||||
}
|
||||
|
||||
const firstCommand = normalizedSearch
|
||||
? (findFirstMatchingCommand(commands, normalizedSearch)?.id ?? "")
|
||||
: "";
|
||||
let firstCommand = "";
|
||||
if (normalizedSearch) {
|
||||
firstCommand = findFirstMatchingCommand(commands, normalizedSearch)?.id ?? "";
|
||||
} else if (commandsLeadResults) {
|
||||
firstCommand = commands.find((command) => !command.disabled && !command.searchOnly)?.id ?? "";
|
||||
}
|
||||
const firstTask = taskResultValues[0] ?? "";
|
||||
return commandsLeadResults ? firstCommand || firstTask : firstTask || firstCommand;
|
||||
}
|
||||
|
||||
function isVisibleCommand(commands: CommandItem[], id: string, search: string): boolean {
|
||||
const command = commands.find((item) => item.id === id);
|
||||
if (!command || command.disabled) return false;
|
||||
return !search || findFirstMatchingCommand(commands, search, id)?.id === id;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { KeyboardShortcut } from "@/lib/keyboard/constants";
|
||||
|
||||
export type CommandPanelMode =
|
||||
| "commands"
|
||||
| "command-children"
|
||||
| "search-tasks"
|
||||
| "search-files"
|
||||
| "search-content"
|
||||
@@ -19,12 +20,17 @@ export type CommandItem = {
|
||||
context?: string;
|
||||
/** Hidden from the idle palette; becomes eligible after the user types. */
|
||||
searchOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: CommandItem[];
|
||||
destructive?: boolean;
|
||||
/** For level-2 transitions: set the mode instead of running an action */
|
||||
enterMode?: CommandPanelMode;
|
||||
/** Standard action — close panel and execute */
|
||||
action?: () => void;
|
||||
/** Keep the palette mounted while an action-owned confirmation is shown. */
|
||||
keepOpen?: boolean;
|
||||
/** Destination action for Cmd/Ctrl+Enter; normal selection opens its options. */
|
||||
immediateAction?: () => void;
|
||||
/** Confirmation content rendered inside the open palette. */
|
||||
confirmation?: ReactNode;
|
||||
/** Clears action-owned confirmation state when the palette itself closes. */
|
||||
|
||||
Reference in New Issue
Block a user