Better keyboard navigation (#189)

* minor bugfix: close panel on escape

* add arrow navigation to kanban board

* show shortcut "C" on the button to create a new task

* move keyboard handler to keyboard-shortcuts.ts

* create a new task attempt and stop it using keyboard navigation

* remove key hints from buttons

* implement arrow navigation for project cards

* add confirmation dialog before stopping executions

* confirm before starting a task if it is in todo column

* fmt

* show start task confirmation only on key press

* create project on C press
This commit is contained in:
Anastasiia Solop
2025-07-16 14:17:40 +02:00
committed by GitHub
parent da5e5d9b32
commit f6b5aae531
12 changed files with 663 additions and 172 deletions

View File

@@ -7,7 +7,7 @@ export function KeyboardShortcutsDemo() {
currentPath: '/demo',
hasOpenDialog: false,
closeDialog: () => {},
openCreateTask: () => {},
onC: () => {},
});
return (

View File

@@ -0,0 +1,155 @@
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card.tsx';
import { Badge } from '@/components/ui/badge.tsx';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
Calendar,
Edit,
ExternalLink,
FolderOpen,
MoreHorizontal,
Trash2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { projectsApi } from '@/lib/api.ts';
import { Project } from 'shared/types.ts';
import { useEffect, useRef } from 'react';
type Props = {
project: Project;
isFocused: boolean;
fetchProjects: () => void;
setError: (error: string) => void;
setEditingProject: (project: Project) => void;
setShowForm: (show: boolean) => void;
};
function ProjectCard({
project,
isFocused,
fetchProjects,
setError,
setEditingProject,
setShowForm,
}: Props) {
const navigate = useNavigate();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isFocused && ref.current) {
ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
ref.current.focus();
}
}, [isFocused]);
const handleDelete = async (id: string, name: string) => {
if (
!confirm(
`Are you sure you want to delete "${name}"? This action cannot be undone.`
)
)
return;
try {
await projectsApi.delete(id);
fetchProjects();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');
}
};
const handleEdit = (project: Project) => {
setEditingProject(project);
setShowForm(true);
};
const handleOpenInIDE = async (projectId: string) => {
try {
await projectsApi.openEditor(projectId);
} catch (error) {
console.error('Failed to open project in IDE:', error);
setError('Failed to open project in IDE');
}
};
return (
<Card
className={`hover:shadow-md transition-shadow cursor-pointer focus:ring-2 focus:ring-primary outline-none`}
onClick={() => navigate(`/projects/${project.id}/tasks`)}
tabIndex={isFocused ? 0 : -1}
ref={ref}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary">Active</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/projects/${project.id}`);
}}
>
<ExternalLink className="mr-2 h-4 w-4" />
View Project
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOpenInIDE(project.id);
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Open in IDE
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(project);
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id, project.name);
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Created {new Date(project.created_at).toLocaleDateString()}
</CardDescription>
</CardHeader>
</Card>
);
}
export default ProjectCard;

View File

@@ -23,6 +23,7 @@ import {
Loader2,
Trash2,
} from 'lucide-react';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
interface ProjectDetailProps {
projectId: string;
@@ -36,6 +37,11 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const [showEditForm, setShowEditForm] = useState(false);
const [error, setError] = useState('');
useKeyboardShortcuts({
navigate,
currentPath: `/projects/${projectId}`,
});
const fetchProject = useCallback(async () => {
setLoading(true);
setError('');

View File

@@ -1,35 +1,17 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
useKanbanKeyboardNavigation,
useKeyboardShortcuts,
} from '@/lib/keyboard-shortcuts';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types';
import { ProjectForm } from './project-form';
import { projectsApi } from '@/lib/api';
import {
AlertCircle,
Calendar,
Edit,
ExternalLink,
FolderOpen,
Loader2,
MoreHorizontal,
Plus,
Trash2,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AlertCircle, Loader2, Plus } from 'lucide-react';
import ProjectCard from '@/components/projects/ProjectCard.tsx';
export function ProjectList() {
const navigate = useNavigate();
@@ -38,6 +20,8 @@ export function ProjectList() {
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [error, setError] = useState('');
const [focusedProjectId, setFocusedProjectId] = useState<string | null>(null);
const [focusedColumn, setFocusedColumn] = useState<string | null>(null);
const fetchProjects = async () => {
setLoading(true);
@@ -54,43 +38,91 @@ export function ProjectList() {
}
};
const handleDelete = async (id: string, name: string) => {
if (
!confirm(
`Are you sure you want to delete "${name}"? This action cannot be undone.`
)
)
return;
try {
await projectsApi.delete(id);
fetchProjects();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');
}
};
const handleEdit = (project: Project) => {
setEditingProject(project);
setShowForm(true);
};
const handleOpenInIDE = async (projectId: string) => {
try {
await projectsApi.openEditor(projectId);
} catch (error) {
console.error('Failed to open project in IDE:', error);
setError('Failed to open project in IDE');
}
};
const handleFormSuccess = () => {
setShowForm(false);
setEditingProject(null);
fetchProjects();
};
// Group projects by grid columns (3 columns for lg, 2 for md, 1 for sm)
const getGridColumns = () => {
const screenWidth = window.innerWidth;
if (screenWidth >= 1024) return 3; // lg
if (screenWidth >= 768) return 2; // md
return 1; // sm
};
const groupProjectsByColumns = (projects: Project[], columns: number) => {
const grouped: Record<string, Project[]> = {};
for (let i = 0; i < columns; i++) {
grouped[`column-${i}`] = [];
}
projects.forEach((project, index) => {
const columnIndex = index % columns;
grouped[`column-${columnIndex}`].push(project);
});
return grouped;
};
const columns = getGridColumns();
const groupedProjects = groupProjectsByColumns(projects, columns);
const allColumnKeys = Object.keys(groupedProjects);
// Set initial focus when projects are loaded
useEffect(() => {
if (projects.length > 0 && !focusedProjectId) {
setFocusedProjectId(projects[0].id);
setFocusedColumn('column-0');
}
}, [projects, focusedProjectId]);
const handleViewProjectDetails = (project: Project) => {
navigate(`/projects/${project.id}/tasks`);
};
// Setup keyboard navigation
useKanbanKeyboardNavigation({
focusedTaskId: focusedProjectId,
setFocusedTaskId: setFocusedProjectId,
focusedStatus: focusedColumn,
setFocusedStatus: setFocusedColumn,
groupedTasks: groupedProjects,
filteredTasks: projects,
allTaskStatuses: allColumnKeys,
onViewTaskDetails: handleViewProjectDetails,
preserveIndexOnColumnSwitch: true,
});
useKeyboardShortcuts({
ignoreEscape: true,
onC: () => setShowForm(true),
navigate,
currentPath: '/projects',
});
// Handle window resize to update column layout
useEffect(() => {
const handleResize = () => {
// Reset focus when layout changes
if (focusedProjectId && projects.length > 0) {
const newColumns = getGridColumns();
// Find which column the focused project should be in
const focusedProject = projects.find((p) => p.id === focusedProjectId);
if (focusedProject) {
const projectIndex = projects.indexOf(focusedProject);
const newColumnIndex = projectIndex % newColumns;
setFocusedColumn(`column-${newColumnIndex}`);
}
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [focusedProjectId, projects]);
useEffect(() => {
fetchProjects();
}, []);
@@ -141,77 +173,15 @@ export function ProjectList() {
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card
<ProjectCard
key={project.id}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/projects/${project.id}/tasks`)}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary">Active</Badge>
<DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/projects/${project.id}`);
}}
>
<ExternalLink className="mr-2 h-4 w-4" />
View Project
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOpenInIDE(project.id);
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Open in IDE
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(project);
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id, project.name);
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Created {new Date(project.created_at).toLocaleDateString()}
</CardDescription>
</CardHeader>
</Card>
project={project}
isFocused={focusedProjectId === project.id}
setError={setError}
setEditingProject={setEditingProject}
setShowForm={setShowForm}
fetchProjects={fetchProjects}
/>
))}
</div>
)}

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -7,11 +8,11 @@ import {
} from '@/components/ui/dropdown-menu';
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
import {
MoreHorizontal,
Trash2,
CheckCircle,
Edit,
Loader2,
CheckCircle,
MoreHorizontal,
Trash2,
XCircle,
} from 'lucide-react';
import type { TaskWithAttemptStatus } from 'shared/types';
@@ -25,6 +26,8 @@ interface TaskCardProps {
onEdit: (task: Task) => void;
onDelete: (taskId: string) => void;
onViewDetails: (task: Task) => void;
isFocused: boolean;
tabIndex?: number;
}
export function TaskCard({
@@ -34,7 +37,17 @@ export function TaskCard({
onEdit,
onDelete,
onViewDetails,
isFocused,
tabIndex = -1,
}: TaskCardProps) {
const localRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isFocused && localRef.current) {
localRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
localRef.current.focus();
}
}, [isFocused]);
return (
<KanbanCard
key={task.id}
@@ -43,6 +56,8 @@ export function TaskCard({
index={index}
parent={status}
onClick={() => onViewDetails(task)}
tabIndex={tabIndex}
forwardedRef={localRef}
>
<div className="space-y-2">
<div className="flex items-start justify-between">

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import {
type DragEndEvent,
KanbanBoard,
@@ -8,6 +8,11 @@ import {
} from '@/components/ui/shadcn-io/kanban';
import { TaskCard } from './TaskCard';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
import { useNavigate, useParams } from 'react-router-dom';
import {
useKeyboardShortcuts,
useKanbanKeyboardNavigation,
} from '@/lib/keyboard-shortcuts.ts';
type Task = TaskWithAttemptStatus;
@@ -52,6 +57,22 @@ function TaskKanbanBoard({
onDeleteTask,
onViewTaskDetails,
}: TaskKanbanBoardProps) {
const { projectId, taskId } = useParams<{
projectId: string;
taskId?: string;
}>();
const navigate = useNavigate();
useKeyboardShortcuts({
navigate,
currentPath: `/projects/${projectId}/tasks${taskId ? `/${taskId}` : ''}`,
});
const [focusedTaskId, setFocusedTaskId] = useState<string | null>(
taskId || null
);
const [focusedStatus, setFocusedStatus] = useState<TaskStatus | null>(null);
// Memoize filtered tasks
const filteredTasks = useMemo(() => {
if (!searchQuery.trim()) {
@@ -82,6 +103,42 @@ function TaskKanbanBoard({
return groups;
}, [filteredTasks]);
// Sync focus state with taskId param
useEffect(() => {
if (taskId) {
const found = filteredTasks.find((t) => t.id === taskId);
if (found) {
setFocusedTaskId(taskId);
setFocusedStatus((found.status.toLowerCase() as TaskStatus) || null);
}
}
}, [taskId, filteredTasks]);
// If no taskId in params, keep last focused, or focus first available
useEffect(() => {
if (!taskId && !focusedTaskId) {
for (const status of allTaskStatuses) {
if (groupedTasks[status] && groupedTasks[status].length > 0) {
setFocusedTaskId(groupedTasks[status][0].id);
setFocusedStatus(status);
break;
}
}
}
}, [taskId, focusedTaskId, groupedTasks]);
// Keyboard navigation handler
useKanbanKeyboardNavigation({
focusedTaskId,
setFocusedTaskId: (id) => setFocusedTaskId(id as string | null),
focusedStatus,
setFocusedStatus: (status) => setFocusedStatus(status as TaskStatus | null),
groupedTasks,
filteredTasks,
allTaskStatuses,
onViewTaskDetails,
});
return (
<KanbanProvider onDragEnd={onDragEnd}>
{Object.entries(groupedTasks).map(([status, statusTasks]) => (
@@ -100,6 +157,8 @@ function TaskKanbanBoard({
onEdit={onEditTask}
onDelete={onDeleteTask}
onViewDetails={onViewTaskDetails}
isFocused={focusedTaskId === task.id}
tabIndex={focusedTaskId === task.id ? 0 : -1}
/>
))}
</KanbanCards>

View File

@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useContext } from 'react';
import { Dispatch, SetStateAction, useCallback, useContext } from 'react';
import { Button } from '@/components/ui/button.tsx';
import { ArrowDown, Play, Settings2, X } from 'lucide-react';
import {
@@ -15,6 +15,16 @@ import {
} from '@/components/context/taskDetailsContext.ts';
import { useConfig } from '@/components/config-provider.tsx';
import BranchSelector from '@/components/tasks/BranchSelector.tsx';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { useState } from 'react';
type Props = {
branches: GitBranch[];
@@ -50,17 +60,63 @@ function CreateAttempt({
const { isAttemptRunning } = useContext(TaskAttemptDataContext);
const { config } = useConfig();
const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => {
try {
await attemptsApi.create(projectId!, task.id, {
executor: executor || selectedExecutor,
base_branch: baseBranch || selectedBranch,
});
fetchTaskAttempts();
} catch (error) {
// Optionally handle error
}
};
const [showCreateAttemptConfirmation, setShowCreateAttemptConfirmation] =
useState(false);
const [pendingExecutor, setPendingExecutor] = useState<string | undefined>(
undefined
);
const [pendingBaseBranch, setPendingBaseBranch] = useState<
string | undefined
>(undefined);
// Create attempt logic
const actuallyCreateAttempt = useCallback(
async (executor?: string, baseBranch?: string) => {
try {
await attemptsApi.create(projectId!, task.id, {
executor: executor || selectedExecutor,
base_branch: baseBranch || selectedBranch,
});
fetchTaskAttempts();
} catch (error) {
// Optionally handle error
}
},
[projectId, task.id, selectedExecutor, selectedBranch, fetchTaskAttempts]
);
// Handler for Enter key or Start button
const onCreateNewAttempt = useCallback(
(executor?: string, baseBranch?: string, isKeyTriggered?: boolean) => {
if (task.status === 'todo' && isKeyTriggered) {
setPendingExecutor(executor);
setPendingBaseBranch(baseBranch);
setShowCreateAttemptConfirmation(true);
} else {
actuallyCreateAttempt(executor, baseBranch);
setShowCreateAttemptConfirmation(false);
setIsInCreateAttemptMode(false);
}
},
[task.status, actuallyCreateAttempt, setIsInCreateAttemptMode]
);
// Keyboard shortcuts
useKeyboardShortcuts({
onEnter: () => {
if (showCreateAttemptConfirmation) {
handleConfirmCreateAttempt();
} else {
onCreateNewAttempt(
createAttemptExecutor,
createAttemptBranch || undefined,
true
);
}
},
hasOpenDialog: showCreateAttemptConfirmation,
closeDialog: () => setShowCreateAttemptConfirmation(false),
});
const handleExitCreateAttemptMode = () => {
setIsInCreateAttemptMode(false);
@@ -68,7 +124,12 @@ function CreateAttempt({
const handleCreateAttempt = () => {
onCreateNewAttempt(createAttemptExecutor, createAttemptBranch || undefined);
handleExitCreateAttemptMode();
};
const handleConfirmCreateAttempt = () => {
actuallyCreateAttempt(pendingExecutor, pendingBaseBranch);
setShowCreateAttemptConfirmation(false);
setIsInCreateAttemptMode(false);
};
return (
@@ -158,7 +219,7 @@ function CreateAttempt({
onClick={handleCreateAttempt}
disabled={!createAttemptExecutor || isAttemptRunning}
size="sm"
className="w-full text-xs"
className="w-full text-xs gap-2"
>
<Play className="h-3 w-3 mr-1.5" />
Start
@@ -166,6 +227,31 @@ function CreateAttempt({
</div>
</div>
</div>
{/* Confirmation Dialog */}
<Dialog
open={showCreateAttemptConfirmation}
onOpenChange={setShowCreateAttemptConfirmation}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Start New Attempt?</DialogTitle>
<DialogDescription>
Are you sure you want to start a new attempt for this task? This
will create a new session and branch.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateAttemptConfirmation(false)}
>
Cancel
</Button>
<Button onClick={handleConfirmCreateAttempt}>Start</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -6,8 +6,8 @@ import {
Play,
Plus,
RefreshCw,
StopCircle,
Settings,
StopCircle,
} from 'lucide-react';
import {
Tooltip,
@@ -55,6 +55,7 @@ import {
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import { useConfig } from '@/components/config-provider.tsx';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
// Helper function to get the display name for different editor types
function getEditorDisplayName(editorType: string): string {
@@ -122,6 +123,7 @@ function CurrentAttempt({
const [branchStatusLoading, setBranchStatusLoading] = useState(false);
const [showRebaseDialog, setShowRebaseDialog] = useState(false);
const [selectedRebaseBranch, setSelectedRebaseBranch] = useState<string>('');
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const processedDevServerLogs = useMemo(() => {
if (!devServerDetails) return 'No output yet...';
@@ -206,8 +208,8 @@ function CurrentAttempt({
}
};
const stopAllExecutions = async () => {
if (!task || !selectedAttempt) return;
const stopAllExecutions = useCallback(async () => {
if (!task || !selectedAttempt || !isAttemptRunning) return;
try {
setIsStopping(true);
@@ -225,7 +227,25 @@ function CurrentAttempt({
} finally {
setIsStopping(false);
}
};
}, [
task,
selectedAttempt,
projectId,
fetchAttemptData,
setIsStopping,
isAttemptRunning,
]);
useKeyboardShortcuts({
stopExecution: () => setShowStopConfirmation(true),
newAttempt: !isAttemptRunning ? handleEnterCreateAttemptMode : () => {},
hasOpenDialog: showStopConfirmation,
closeDialog: () => setShowStopConfirmation(false),
onEnter: () => {
setShowStopConfirmation(false);
stopAllExecutions();
},
});
const handleAttemptChange = useCallback(
(attempt: TaskAttempt) => {
@@ -702,6 +722,41 @@ function CurrentAttempt({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Stop Execution Confirmation Dialog */}
<Dialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Stop Current Attempt?</DialogTitle>
<DialogDescription>
Are you sure you want to stop the current execution? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowStopConfirmation(false)}
disabled={isStopping}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
setShowStopConfirmation(false);
await stopAllExecutions();
}}
disabled={isStopping}
>
{isStopping ? 'Stopping...' : 'Stop'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
@@ -11,8 +12,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type { ReactNode } from 'react';
import type { ReactNode, Ref } from 'react';
export type { DragEndEvent } from '@dnd-kit/core';
@@ -59,6 +59,8 @@ export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
children?: ReactNode;
className?: string;
onClick?: () => void;
tabIndex?: number;
forwardedRef?: Ref<HTMLDivElement>;
};
export const KanbanCard = ({
@@ -69,6 +71,8 @@ export const KanbanCard = ({
children,
className,
onClick,
tabIndex,
forwardedRef,
}: KanbanCardProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
@@ -76,10 +80,21 @@ export const KanbanCard = ({
data: { index, parent },
});
// Combine DnD ref and forwarded ref
const combinedRef = (node: HTMLDivElement | null) => {
setNodeRef(node);
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef && typeof forwardedRef === 'object') {
(forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
};
return (
<Card
className={cn(
'rounded-md p-3 shadow-sm',
'rounded-md p-3 shadow-sm focus:ring-2 focus:ring-primary outline-none',
isDragging && 'cursor-grabbing',
className
)}
@@ -90,7 +105,8 @@ export const KanbanCard = ({
}}
{...listeners}
{...attributes}
ref={setNodeRef}
ref={combinedRef}
tabIndex={tabIndex}
onClick={onClick}
>
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
// Define available keyboard shortcuts
export interface KeyboardShortcut {
@@ -13,10 +13,14 @@ export interface KeyboardShortcut {
export interface KeyboardShortcutContext {
navigate?: ReturnType<typeof useNavigate>;
closeDialog?: () => void;
openCreateTask?: () => void;
onC?: () => void;
currentPath?: string;
hasOpenDialog?: boolean;
location?: ReturnType<typeof useLocation>;
stopExecution?: () => void;
newAttempt?: () => void;
onEnter?: () => void;
ignoreEscape?: boolean;
}
// Centralized shortcut definitions
@@ -27,6 +31,10 @@ export const createKeyboardShortcuts = (
key: 'Escape',
description: 'Go back or close dialog',
action: () => {
if (context.ignoreEscape) {
return;
}
// If there's an open dialog, close it
if (context.hasOpenDialog && context.closeDialog) {
context.closeDialog();
@@ -59,15 +67,38 @@ export const createKeyboardShortcuts = (
}
},
},
Enter: {
key: 'Enter',
description: 'Enter or submit',
action: () => {
if (context.onEnter) {
context.onEnter();
}
},
},
KeyC: {
key: 'c',
description: 'Create new task',
action: () => {
if (context.openCreateTask) {
context.openCreateTask();
if (context.onC) {
context.onC();
}
},
},
KeyS: {
key: 's',
description: 'Stop all executions',
action: () => {
context.stopExecution && context.stopExecution();
},
},
KeyN: {
key: 'n',
description: 'Create new task attempt',
action: () => {
context.newAttempt && context.newAttempt();
},
},
});
// Hook to register global keyboard shortcuts
@@ -123,3 +154,113 @@ export function useDialogKeyboardShortcuts(onClose: () => void) {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
}
// Kanban board keyboard navigation hook
export function useKanbanKeyboardNavigation({
focusedTaskId,
setFocusedTaskId,
focusedStatus,
setFocusedStatus,
groupedTasks,
filteredTasks,
allTaskStatuses,
onViewTaskDetails,
preserveIndexOnColumnSwitch = false,
}: {
focusedTaskId: string | null;
setFocusedTaskId: (id: string | null) => void;
focusedStatus: string | null;
setFocusedStatus: (status: string | null) => void;
groupedTasks: Record<string, any[]>;
filteredTasks: any[];
allTaskStatuses: string[];
onViewTaskDetails: (task: any) => void;
preserveIndexOnColumnSwitch?: boolean;
}) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
// Don't handle if typing in input, textarea, or select
const tag = (e.target as HTMLElement)?.tagName;
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(e.target as HTMLElement)?.isContentEditable
)
return;
if (!focusedTaskId || !focusedStatus) return;
const currentColumn = groupedTasks[focusedStatus];
const currentIndex = currentColumn.findIndex(
(t: any) => t.id === focusedTaskId
);
let newStatus = focusedStatus;
let newTaskId = focusedTaskId;
if (e.key === 'ArrowDown') {
if (currentIndex < currentColumn.length - 1) {
newTaskId = currentColumn[currentIndex + 1].id;
}
} else if (e.key === 'ArrowUp') {
if (currentIndex > 0) {
newTaskId = currentColumn[currentIndex - 1].id;
}
} else if (e.key === 'ArrowRight') {
let colIdx = allTaskStatuses.indexOf(focusedStatus);
while (colIdx < allTaskStatuses.length - 1) {
colIdx++;
const nextStatus = allTaskStatuses[colIdx];
if (groupedTasks[nextStatus] && groupedTasks[nextStatus].length > 0) {
newStatus = nextStatus;
if (preserveIndexOnColumnSwitch) {
const nextCol = groupedTasks[nextStatus];
const idx = Math.min(currentIndex, nextCol.length - 1);
newTaskId = nextCol[idx].id;
} else {
newTaskId = groupedTasks[nextStatus][0].id;
}
break;
}
}
} else if (e.key === 'ArrowLeft') {
let colIdx = allTaskStatuses.indexOf(focusedStatus);
while (colIdx > 0) {
colIdx--;
const prevStatus = allTaskStatuses[colIdx];
if (groupedTasks[prevStatus] && groupedTasks[prevStatus].length > 0) {
newStatus = prevStatus;
if (preserveIndexOnColumnSwitch) {
const prevCol = groupedTasks[prevStatus];
const idx = Math.min(currentIndex, prevCol.length - 1);
newTaskId = prevCol[idx].id;
} else {
newTaskId = groupedTasks[prevStatus][0].id;
}
break;
}
}
} else if (e.key === 'Enter' || e.key === ' ') {
const task = filteredTasks.find((t: any) => t.id === focusedTaskId);
if (task) {
onViewTaskDetails(task);
}
} else {
return;
}
e.preventDefault();
setFocusedTaskId(newTaskId);
setFocusedStatus(newStatus);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
focusedTaskId,
focusedStatus,
groupedTasks,
filteredTasks,
onViewTaskDetails,
allTaskStatuses,
setFocusedTaskId,
setFocusedStatus,
preserveIndexOnColumnSwitch,
]);
}

View File

@@ -69,7 +69,7 @@ export function ProjectTasks() {
currentPath: `/projects/${projectId}/tasks`,
hasOpenDialog: isTaskDialogOpen,
closeDialog: () => setIsTaskDialogOpen(false),
openCreateTask: handleCreateNewTask,
onC: handleCreateNewTask,
});
useEffect(() => {
@@ -98,10 +98,8 @@ export function ProjectTasks() {
});
setIsPanelOpen(true);
}
} else {
// Close panel when no taskId in URL
} else if (!taskId) {
setIsPanelOpen(false);
setSelectedTask(null);
}
}, [taskId, tasks]);

View File

@@ -1,7 +1,6 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ProjectList } from '@/components/projects/project-list';
import { ProjectDetail } from '@/components/projects/project-detail';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
export function Projects() {
const { projectId } = useParams<{ projectId: string }>();
@@ -11,15 +10,6 @@ export function Projects() {
navigate('/projects');
};
// Setup keyboard shortcuts (only Esc for back navigation, no task creation here)
useKeyboardShortcuts({
navigate,
currentPath: projectId ? `/projects/${projectId}` : '/projects',
hasOpenDialog: false,
closeDialog: () => {},
openCreateTask: () => {}, // No-op for projects page
});
if (projectId) {
return <ProjectDetail projectId={projectId} onBack={handleBack} />;
}