From f6b5aae5314eb74a214d9e3be23e822b59e999b9 Mon Sep 17 00:00:00 2001 From: Anastasiia Solop <35258279+anastasiya1155@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:17:40 +0200 Subject: [PATCH] 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 --- .../components/keyboard-shortcuts-demo.tsx | 2 +- .../src/components/projects/ProjectCard.tsx | 155 ++++++++++++ .../components/projects/project-detail.tsx | 6 + .../src/components/projects/project-list.tsx | 222 ++++++++---------- frontend/src/components/tasks/TaskCard.tsx | 21 +- .../src/components/tasks/TaskKanbanBoard.tsx | 61 ++++- .../tasks/Toolbar/CreateAttempt.tsx | 114 +++++++-- .../tasks/Toolbar/CurrentAttempt.tsx | 63 ++++- .../components/ui/shadcn-io/kanban/index.tsx | 24 +- frontend/src/lib/keyboard-shortcuts.ts | 151 +++++++++++- frontend/src/pages/project-tasks.tsx | 6 +- frontend/src/pages/projects.tsx | 10 - 12 files changed, 663 insertions(+), 172 deletions(-) create mode 100644 frontend/src/components/projects/ProjectCard.tsx diff --git a/frontend/src/components/keyboard-shortcuts-demo.tsx b/frontend/src/components/keyboard-shortcuts-demo.tsx index e7dfc95a..e7328456 100644 --- a/frontend/src/components/keyboard-shortcuts-demo.tsx +++ b/frontend/src/components/keyboard-shortcuts-demo.tsx @@ -7,7 +7,7 @@ export function KeyboardShortcutsDemo() { currentPath: '/demo', hasOpenDialog: false, closeDialog: () => {}, - openCreateTask: () => {}, + onC: () => {}, }); return ( diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx new file mode 100644 index 00000000..9d37c816 --- /dev/null +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -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(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 ( + navigate(`/projects/${project.id}/tasks`)} + tabIndex={isFocused ? 0 : -1} + ref={ref} + > + +
+ {project.name} +
+ Active + + e.stopPropagation()}> + + + + { + e.stopPropagation(); + navigate(`/projects/${project.id}`); + }} + > + + View Project + + { + e.stopPropagation(); + handleOpenInIDE(project.id); + }} + > + + Open in IDE + + { + e.stopPropagation(); + handleEdit(project); + }} + > + + Edit + + { + e.stopPropagation(); + handleDelete(project.id, project.name); + }} + className="text-destructive" + > + + Delete + + + +
+
+ + + Created {new Date(project.created_at).toLocaleDateString()} + +
+
+ ); +} + +export default ProjectCard; diff --git a/frontend/src/components/projects/project-detail.tsx b/frontend/src/components/projects/project-detail.tsx index 7c737a71..20ff9f07 100644 --- a/frontend/src/components/projects/project-detail.tsx +++ b/frontend/src/components/projects/project-detail.tsx @@ -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(''); diff --git a/frontend/src/components/projects/project-list.tsx b/frontend/src/components/projects/project-list.tsx index 065aa32a..7e1884bb 100644 --- a/frontend/src/components/projects/project-list.tsx +++ b/frontend/src/components/projects/project-list.tsx @@ -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(null); const [error, setError] = useState(''); + const [focusedProjectId, setFocusedProjectId] = useState(null); + const [focusedColumn, setFocusedColumn] = useState(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 = {}; + 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() { ) : (
{projects.map((project) => ( - navigate(`/projects/${project.id}/tasks`)} - > - -
- {project.name} -
- Active - - e.stopPropagation()} - > - - - - { - e.stopPropagation(); - navigate(`/projects/${project.id}`); - }} - > - - View Project - - { - e.stopPropagation(); - handleOpenInIDE(project.id); - }} - > - - Open in IDE - - { - e.stopPropagation(); - handleEdit(project); - }} - > - - Edit - - { - e.stopPropagation(); - handleDelete(project.id, project.name); - }} - className="text-destructive" - > - - Delete - - - -
-
- - - Created {new Date(project.created_at).toLocaleDateString()} - -
-
+ project={project} + isFocused={focusedProjectId === project.id} + setError={setError} + setEditingProject={setEditingProject} + setShowForm={setShowForm} + fetchProjects={fetchProjects} + /> ))}
)} diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index 4b0f1496..9cc39380 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -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(null); + useEffect(() => { + if (isFocused && localRef.current) { + localRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + localRef.current.focus(); + } + }, [isFocused]); + return ( onViewDetails(task)} + tabIndex={tabIndex} + forwardedRef={localRef} >
diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index d8afa7c2..623f512d 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -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( + taskId || null + ); + const [focusedStatus, setFocusedStatus] = useState(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 ( {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} /> ))} diff --git a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx index e99c9262..a5d4f2bd 100644 --- a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx @@ -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( + 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" > Start @@ -166,6 +227,31 @@ function CreateAttempt({
+ + {/* Confirmation Dialog */} + + + + Start New Attempt? + + Are you sure you want to start a new attempt for this task? This + will create a new session and branch. + + + + + + + + ); } diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx index 7af637f0..e6be20a9 100644 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx @@ -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(''); + 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({ + + {/* Stop Execution Confirmation Dialog */} + + + + Stop Current Attempt? + + Are you sure you want to stop the current execution? This action + cannot be undone. + + + + + + + + ); } diff --git a/frontend/src/components/ui/shadcn-io/kanban/index.tsx b/frontend/src/components/ui/shadcn-io/kanban/index.tsx index 6b1cf1e5..66cb2f2d 100644 --- a/frontend/src/components/ui/shadcn-io/kanban/index.tsx +++ b/frontend/src/components/ui/shadcn-io/kanban/index.tsx @@ -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 & { children?: ReactNode; className?: string; onClick?: () => void; + tabIndex?: number; + forwardedRef?: Ref; }; 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).current = + node; + } + }; + return ( {children ??

{name}

} diff --git a/frontend/src/lib/keyboard-shortcuts.ts b/frontend/src/lib/keyboard-shortcuts.ts index 385d7100..1db7f36d 100644 --- a/frontend/src/lib/keyboard-shortcuts.ts +++ b/frontend/src/lib/keyboard-shortcuts.ts @@ -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; closeDialog?: () => void; - openCreateTask?: () => void; + onC?: () => void; currentPath?: string; hasOpenDialog?: boolean; location?: ReturnType; + 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; + 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, + ]); +} diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index ad3d5cdd..de8e0e66 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -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]); diff --git a/frontend/src/pages/projects.tsx b/frontend/src/pages/projects.tsx index cd9d562b..e12b88d0 100644 --- a/frontend/src/pages/projects.tsx +++ b/frontend/src/pages/projects.tsx @@ -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 ; }