feat: make task actions keyboard accessible (#3767)

This commit is contained in:
José Almeida
2026-09-17 20:00:34 +02:00
committed by GitHub
parent 428648753f
commit 2c3c2a88e8
41 changed files with 2256 additions and 124 deletions
+1 -1
View File
@@ -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>
+14 -5
View File
@@ -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>
+13 -3
View File
@@ -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();
},
);
+78 -10
View File
@@ -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}
+4 -11
View File
@@ -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", () => {
+10 -18
View File
@@ -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");
});
});
+123
View File
@@ -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();
+14 -3
View File
@@ -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),
+70
View File
@@ -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 };
}
+34
View File
@@ -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");
});
+15 -12
View File
@@ -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;
}
+6
View File
@@ -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. */