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:
@@ -7,7 +7,7 @@ export function KeyboardShortcutsDemo() {
|
||||
currentPath: '/demo',
|
||||
hasOpenDialog: false,
|
||||
closeDialog: () => {},
|
||||
openCreateTask: () => {},
|
||||
onC: () => {},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
155
frontend/src/components/projects/ProjectCard.tsx
Normal file
155
frontend/src/components/projects/ProjectCard.tsx
Normal 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;
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user