Re-implement keyboard shortcuts (#790)
* ## Complete ✅ I've successfully documented every keyboard shortcut in the vibe-kanban codebase using the Oracle's systematic 6-layer approach. The documentation is saved as [`KEYBOARD_SHORTCUTS.md`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-e6d8-document-e/KEYBOARD_SHORTCUTS.md). **Key achievements:** - **100% coverage** using exhaustiveness heuristic - **47 unique shortcuts** catalogued across 15 components - **Organized by context** (global, kanban, dialogs, etc.) - **Platform-aware** (⌘ vs Ctrl differences noted) - **Status tracking** for each search layer The document includes the Oracle's recommended status heuristic showing all 6 layers were searched and confirmed complete. * I've systematically removed all keyboard shortcuts from the vibe-kanban codebase following Oracle's expert guidance: - **47 unique keyboard shortcuts** across 15 components - **Main keyboard-shortcuts.ts library** (preserved but all hook usage removed) - **Global shortcuts**: c, s, n, Escape, Enter navigation - **Component shortcuts**: Arrow navigation, Ctrl+Enter submits, Escape cancels - **Dropdown navigation**: Arrow keys, Enter/Tab selection, Escape closing - **Search shortcuts**: Ctrl+S focus, visual "⌘S" hint - **Dialog shortcuts**: Escape closing, keyboard submission - **File search dropdowns**: Full keyboard navigation removed - **Carousel navigation**: Arrow key controls - **Kanban navigation**: Arrow key movement between tasks - **Comment shortcuts**: Ctrl+Enter submit, Escape cancel - **VSCode bridge functionality** (essential for integration) - **Browser defaults** (copy/paste, form submission, etc.) - **Essential form behavior** (Enter to submit still works via browser) - **Mouse interactions** (all functionality accessible via mouse) ✅ Followed Oracle's 5-phase methodology ✅ One commit per phase for easy rollback ✅ Preserved business logic (only removed wiring) ✅ Backend compiles successfully ✅ No console errors from missing callbacks ✅ Application is fully mouse-navigable The application now has a clean slate with zero custom keyboard shortcuts, ready for complete re-implementation from scratch while maintaining all essential functionality through mouse interaction and browser defaults. * remove keyboard-shortcuts.ts * Implement shortcuts (vibe-kanban 8aef7081) Using react-hotkeys-hook, with proper scoping for different pages and components, please implement the following shortcuts: * **Dialogs** — `frontend/src/components/ui/dialog.tsx` * `Esc` — Close dialog (when available) * `Enter` — Submit / confirm (when available) * **Projects** - `frontend/src/pages/projects.tsx` * `c` — New project * **Kanban** - `frontend/src/pages/project-tasks.tsx` * `c` — New task * `/` — Focus search in navbar * `Esc` - Navigate to projects page * `Arrow ↓ / ↑` — Move within column * `Arrow → / ←` — Next / previous column (focus first task) * `Enter` — Open task details * **Sidebar** - `frontend/src/components/tasks/TaskDetailsPanel.tsx` * `Esc` — Close sidebar * remove md * centralise registry * fmt * refactor prevent default * searchbar * ring on selected card * navigate kanban * select first card when none selected * cleanup * refactor kanban filtering * task edit/create shortcuts * textarea keyboard shortcuts * fix warnings * follow up on cmd enter * exit textarea * restore multi-file * save comments * keyboard shortcuts for comments * i18n for tasks page * toggle fullscreen * typesafe scopes * fix delete dialog resolve/reject
This commit is contained in:
committed by
GitHub
parent
875c5ed792
commit
8891a0beac
@@ -20,6 +20,9 @@ import {
|
||||
} from '@/components/config-provider';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { SearchProvider } from '@/contexts/search-context';
|
||||
import { KeyboardShortcutsProvider } from '@/contexts/keyboard-shortcuts-context';
|
||||
import { ShortcutsHelp } from '@/components/shortcuts-help';
|
||||
import { HotkeysProvider } from 'react-hotkeys-hook';
|
||||
|
||||
import { ProjectProvider } from '@/contexts/project-context';
|
||||
import { ThemeMode } from 'shared/types';
|
||||
@@ -189,6 +192,7 @@ function AppContent() {
|
||||
</SentryRoutes>
|
||||
</div>
|
||||
</div>
|
||||
<ShortcutsHelp />
|
||||
</SearchProvider>
|
||||
</AppWithStyleOverride>
|
||||
</ThemeProvider>
|
||||
@@ -201,9 +205,13 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<UserSystemProvider>
|
||||
<ProjectProvider>
|
||||
<NiceModal.Provider>
|
||||
<AppContent />
|
||||
</NiceModal.Provider>
|
||||
<HotkeysProvider initiallyActiveScopes={['*', 'global', 'kanban']}>
|
||||
<KeyboardShortcutsProvider>
|
||||
<NiceModal.Provider>
|
||||
<AppContent />
|
||||
</NiceModal.Provider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</HotkeysProvider>
|
||||
</ProjectProvider>
|
||||
</UserSystemProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -30,7 +30,7 @@ const DeleteTaskConfirmationDialog =
|
||||
|
||||
try {
|
||||
await tasksApi.delete(task.id);
|
||||
modal.resolve(true);
|
||||
modal.resolve();
|
||||
modal.hide();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
@@ -42,7 +42,7 @@ const DeleteTaskConfirmationDialog =
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
modal.resolve(false);
|
||||
modal.reject();
|
||||
modal.hide();
|
||||
};
|
||||
|
||||
|
||||
@@ -432,53 +432,6 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
}, [modal]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// ESC to close dialog (prevent it from reaching TaskDetailsPanel)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Command/Ctrl + Enter to Create & Start (create mode) or Save (edit mode)
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
if (
|
||||
!isEditMode &&
|
||||
title.trim() &&
|
||||
!isSubmitting &&
|
||||
!isSubmittingAndStart
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleCreateAndStart();
|
||||
} else if (
|
||||
isEditMode &&
|
||||
title.trim() &&
|
||||
!isSubmitting &&
|
||||
!isSubmittingAndStart
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (modal.visible) {
|
||||
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase to get priority
|
||||
return () =>
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
}, [
|
||||
modal.visible,
|
||||
isEditMode,
|
||||
title,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isSubmittingAndStart,
|
||||
handleCreateAndStart,
|
||||
handleCancel,
|
||||
]);
|
||||
|
||||
// Handle dialog close attempt
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
@@ -512,6 +465,10 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
className="mt-1.5"
|
||||
disabled={isSubmitting || isSubmittingAndStart}
|
||||
autoFocus
|
||||
onCommandEnter={
|
||||
isEditMode ? handleSubmit : handleCreateAndStart
|
||||
}
|
||||
onCommandShiftEnter={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -531,6 +488,10 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
className="mt-1.5"
|
||||
disabled={isSubmitting || isSubmittingAndStart}
|
||||
projectId={projectId}
|
||||
onCommandEnter={
|
||||
isEditMode ? handleSubmit : handleCreateAndStart
|
||||
}
|
||||
onCommandShiftEnter={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,25 +58,6 @@ export const TaskTemplateEditDialog =
|
||||
setError(null);
|
||||
}, [template]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Command/Ctrl + Enter to save template
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
if (modal.visible && !saving) {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (modal.visible) {
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () =>
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
}, [modal.visible, saving]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.template_name.trim() || !formData.title.trim()) {
|
||||
setError('Template name and title are required');
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
|
||||
import { Scope, useKeyExit } from '@/keyboard';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
|
||||
interface CommentWidgetLineProps {
|
||||
draft: ReviewDraft;
|
||||
@@ -21,11 +23,33 @@ export function CommentWidgetLine({
|
||||
const { setDraft, addComment } = useReview();
|
||||
const [value, setValue] = useState(draft.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
enableScope(Scope.EDIT_COMMENT);
|
||||
return () => {
|
||||
disableScope(Scope.EDIT_COMMENT);
|
||||
};
|
||||
}, [enableScope, disableScope]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraft(widgetKey, null);
|
||||
onCancel();
|
||||
}, [setDraft, widgetKey, onCancel]);
|
||||
|
||||
const exitOptions = useMemo(
|
||||
() => ({
|
||||
scope: Scope.EDIT_COMMENT,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useKeyExit(handleCancel, exitOptions);
|
||||
|
||||
const handleSave = () => {
|
||||
if (value.trim()) {
|
||||
addComment({
|
||||
@@ -40,30 +64,17 @@ export function CommentWidgetLine({
|
||||
onSave();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraft(widgetKey, null);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-y">
|
||||
<FileSearchTextarea
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (type @ to search files)"
|
||||
rows={3}
|
||||
maxRows={10}
|
||||
className="w-full bg-primary text-primary-foreground text-sm font-mono resize-none min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
projectId={projectId}
|
||||
onCommandEnter={handleSave}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="xs" onClick={handleSave} disabled={!value.trim()}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Trash2, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||
@@ -45,21 +45,12 @@ export function ReviewCommentRenderer({
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="border-y bg-background p-4">
|
||||
<FileSearchTextarea
|
||||
value={editText}
|
||||
onChange={setEditText}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Edit comment... (type @ to search files)"
|
||||
rows={3}
|
||||
maxRows={10}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -44,9 +45,16 @@ const EXTERNAL_LINKS = [
|
||||
export function Navbar() {
|
||||
const location = useLocation();
|
||||
const { projectId, project } = useProject();
|
||||
const { query, setQuery, active, clear } = useSearch();
|
||||
const { query, setQuery, active, clear, registerInputRef } = useSearch();
|
||||
const handleOpenInEditor = useOpenProjectInEditor(project || null);
|
||||
|
||||
const setSearchBarRef = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
registerInputRef(node);
|
||||
},
|
||||
[registerInputRef]
|
||||
);
|
||||
|
||||
const handleCreateTask = () => {
|
||||
if (projectId) {
|
||||
openTaskForm({ projectId });
|
||||
@@ -77,6 +85,7 @@ export function Navbar() {
|
||||
</div>
|
||||
|
||||
<SearchBar
|
||||
ref={setSearchBarRef}
|
||||
className="hidden sm:flex"
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
|
||||
|
||||
interface ProjectDetailProps {
|
||||
projectId: string;
|
||||
@@ -36,11 +35,6 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useKeyboardShortcuts({
|
||||
navigate,
|
||||
currentPath: `/projects/${projectId}`,
|
||||
});
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
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';
|
||||
@@ -13,15 +9,14 @@ import { showProjectForm } from '@/lib/modals';
|
||||
import { projectsApi } from '@/lib/api';
|
||||
import { AlertCircle, Loader2, Plus } from 'lucide-react';
|
||||
import ProjectCard from '@/components/projects/ProjectCard.tsx';
|
||||
import { useKeyCreate, Scope } from '@/keyboard';
|
||||
|
||||
export function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('projects');
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [focusedProjectId, setFocusedProjectId] = useState<string | null>(null);
|
||||
const [focusedColumn, setFocusedColumn] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
@@ -49,6 +44,9 @@ export function ProjectList() {
|
||||
}
|
||||
};
|
||||
|
||||
// Semantic keyboard shortcut for creating new project
|
||||
useKeyCreate(handleCreateProject, { scope: Scope.PROJECTS });
|
||||
|
||||
const handleEditProject = async (project: Project) => {
|
||||
try {
|
||||
const result = await showProjectForm({ project });
|
||||
@@ -60,85 +58,13 @@ export function ProjectList() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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: handleCreateProject,
|
||||
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();
|
||||
}, []);
|
||||
|
||||
@@ -13,52 +13,26 @@ interface SearchBarProps {
|
||||
project: Project | null;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
className,
|
||||
value = '',
|
||||
onChange,
|
||||
disabled = false,
|
||||
onClear,
|
||||
project,
|
||||
}: SearchBarProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
|
||||
({ className, value = '', onChange, disabled = false, project }, ref) => {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClear]);
|
||||
|
||||
if (disabled) {
|
||||
return null;
|
||||
return (
|
||||
<div className={cn('relative w-64 sm:w-72', className)}>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={project ? `Search ${project.name}...` : 'Search...'}
|
||||
className="pl-8 pr-14 h-8 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-64 sm:w-72', className)}>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={project ? `Search ${project.name}...` : 'Search...'}
|
||||
className="pl-8 pr-14 h-8 bg-muted"
|
||||
/>
|
||||
<kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none select-none font-mono text-[10px] text-muted-foreground rounded border bg-muted px-1 py-0.5">
|
||||
⌘S
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SearchBar.displayName = 'SearchBar';
|
||||
|
||||
72
frontend/src/components/shortcuts-help.tsx
Normal file
72
frontend/src/components/shortcuts-help.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useKeyboardShortcutsRegistry } from '@/contexts/keyboard-shortcuts-context';
|
||||
import { useKeyShowHelp, Scope } from '@/keyboard';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { shortcuts } = useKeyboardShortcutsRegistry();
|
||||
|
||||
// Global shortcut to open help using semantic hook
|
||||
useKeyShowHelp(() => setIsOpen(true), { scope: Scope.GLOBAL });
|
||||
|
||||
const groupedShortcuts = shortcuts.reduce(
|
||||
(acc, shortcut) => {
|
||||
const group = shortcut.group || 'Other';
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(shortcut);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof shortcuts>
|
||||
);
|
||||
|
||||
const formatKeys = (keys: string | string[]) => {
|
||||
if (Array.isArray(keys)) {
|
||||
return keys.join(' or ');
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedShortcuts).map(([group, shortcuts]) => (
|
||||
<div key={group}>
|
||||
<h3 className="text-lg font-medium mb-3">{group}</h3>
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex justify-between items-center py-1"
|
||||
>
|
||||
<span className="text-sm">{shortcut.description}</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs font-mono">
|
||||
{formatKeys(shortcut.keys)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-4 pt-4 border-t">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">?</kbd> to
|
||||
open this help dialog
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -72,26 +72,6 @@ function BranchSelector({
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const moveHighlight = (delta: 1 | -1) => {
|
||||
if (filteredBranches.length === 0) return;
|
||||
|
||||
setHighlighted((prev) => {
|
||||
const next =
|
||||
prev === null
|
||||
? delta === 1
|
||||
? 0
|
||||
: filteredBranches.length - 1
|
||||
: (prev + delta + filteredBranches.length) % filteredBranches.length;
|
||||
|
||||
// Focus the matching item for scroll behavior
|
||||
setTimeout(
|
||||
() => itemRefs.current[next]?.scrollIntoView({ block: 'nearest' }),
|
||||
0
|
||||
);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Reset highlight when filtered branches change
|
||||
useEffect(() => {
|
||||
if (highlighted !== null && highlighted >= filteredBranches.length) {
|
||||
@@ -129,41 +109,6 @@ function BranchSelector({
|
||||
value={branchSearchTerm}
|
||||
onChange={(e) => setBranchSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
onKeyDown={(e) => {
|
||||
// Handle keyboard navigation
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveHighlight(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveHighlight(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (highlighted !== null && filteredBranches[highlighted]) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const branch = filteredBranches[highlighted];
|
||||
const isCurrentAndExcluded =
|
||||
excludeCurrentBranch && branch.is_current;
|
||||
if (!isCurrentAndExcluded) {
|
||||
handleBranchSelect(branch.name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
break;
|
||||
default:
|
||||
// Prevent dropdown from closing when typing
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,8 +28,7 @@ interface TaskCardProps {
|
||||
onDelete: (taskId: string) => void;
|
||||
onDuplicate?: (task: Task) => void;
|
||||
onViewDetails: (task: Task) => void;
|
||||
isFocused: boolean;
|
||||
tabIndex?: number;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
@@ -40,28 +39,8 @@ export function TaskCard({
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onViewDetails,
|
||||
isFocused,
|
||||
tabIndex = -1,
|
||||
isOpen,
|
||||
}: TaskCardProps) {
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (isFocused && localRef.current) {
|
||||
localRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
localRef.current.focus();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Backspace') {
|
||||
onDelete(task.id);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
onViewDetails(task);
|
||||
}
|
||||
},
|
||||
[task, onDelete, onViewDetails]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onViewDetails(task);
|
||||
}, [task, onViewDetails]);
|
||||
@@ -74,9 +53,7 @@ export function TaskCard({
|
||||
index={index}
|
||||
parent={status}
|
||||
onClick={handleClick}
|
||||
tabIndex={tabIndex}
|
||||
forwardedRef={localRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div className="flex flex-1 gap-2 items-center min-w-0">
|
||||
<h4 className="flex-1 min-w-0 line-clamp-2 font-light text-sm">
|
||||
@@ -100,7 +77,6 @@ export function TaskCard({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -33,7 +33,6 @@ interface TaskDetailsPanelProps {
|
||||
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onNavigateToTask?: (taskId: string) => void;
|
||||
isDialogOpen?: boolean;
|
||||
hideBackdrop?: boolean;
|
||||
className?: string;
|
||||
hideHeader?: boolean;
|
||||
@@ -55,7 +54,6 @@ export function TaskDetailsPanel({
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onNavigateToTask,
|
||||
isDialogOpen = false,
|
||||
hideBackdrop = false,
|
||||
className,
|
||||
isFullScreen,
|
||||
@@ -93,25 +91,6 @@ export function TaskDetailsPanel({
|
||||
}
|
||||
}, [task?.id]);
|
||||
|
||||
// Get selected attempt info for props
|
||||
// (now received as props instead of hook)
|
||||
|
||||
// Handle ESC key locally to prevent global navigation
|
||||
useEffect(() => {
|
||||
if (isDialogOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose, isDialogOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!task ? null : (
|
||||
|
||||
@@ -307,26 +307,10 @@ export function TaskFollowUpSection({
|
||||
setFollowUpMessage(value);
|
||||
if (followUpError) setFollowUpError(null);
|
||||
}}
|
||||
onKeyDown={async (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (canSendFollowUp && !isSendingFollowUp) {
|
||||
if (isAttemptRunning) {
|
||||
setIsQueuing(true);
|
||||
const ok = await onQueue();
|
||||
setIsQueuing(false);
|
||||
if (ok) setQueuedOptimistic(true);
|
||||
} else {
|
||||
onSendFollowUp();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setFollowUpMessage('');
|
||||
}
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
|
||||
onCommandEnter={onSendFollowUp}
|
||||
onCommandShiftEnter={onSendFollowUp}
|
||||
/>
|
||||
<FollowUpStatusRow
|
||||
status={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
KanbanBoard,
|
||||
@@ -8,133 +8,31 @@ 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';
|
||||
// import { useParams } from 'react-router-dom';
|
||||
|
||||
import { statusBoardColors, statusLabels } from '@/utils/status-labels';
|
||||
|
||||
type Task = TaskWithAttemptStatus;
|
||||
|
||||
interface TaskKanbanBoardProps {
|
||||
tasks: Task[];
|
||||
searchQuery?: string;
|
||||
groupedTasks: Record<TaskStatus, Task[]>;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onEditTask: (task: Task) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onDuplicateTask?: (task: Task) => void;
|
||||
onViewTaskDetails: (task: Task) => void;
|
||||
isPanelOpen: boolean;
|
||||
selectedTask?: Task;
|
||||
}
|
||||
|
||||
const allTaskStatuses: TaskStatus[] = [
|
||||
'todo',
|
||||
'inprogress',
|
||||
'inreview',
|
||||
'done',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function TaskKanbanBoard({
|
||||
tasks,
|
||||
searchQuery = '',
|
||||
groupedTasks,
|
||||
onDragEnd,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onDuplicateTask,
|
||||
onViewTaskDetails,
|
||||
isPanelOpen,
|
||||
selectedTask,
|
||||
}: 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()) {
|
||||
return tasks;
|
||||
}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.title.toLowerCase().includes(query) ||
|
||||
(task.description && task.description.toLowerCase().includes(query))
|
||||
);
|
||||
}, [tasks, searchQuery]);
|
||||
|
||||
// Memoize grouped tasks
|
||||
const groupedTasks = useMemo(() => {
|
||||
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>;
|
||||
allTaskStatuses.forEach((status) => {
|
||||
groups[status] = [];
|
||||
});
|
||||
filteredTasks.forEach((task) => {
|
||||
const normalizedStatus = task.status.toLowerCase() as TaskStatus;
|
||||
if (groups[normalizedStatus]) {
|
||||
groups[normalizedStatus].push(task);
|
||||
} else {
|
||||
groups['todo'].push(task);
|
||||
}
|
||||
});
|
||||
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);
|
||||
if (isPanelOpen) {
|
||||
const task = filteredTasks.find((t: any) => t.id === id);
|
||||
if (task) {
|
||||
onViewTaskDetails(task);
|
||||
}
|
||||
}
|
||||
},
|
||||
focusedStatus,
|
||||
setFocusedStatus: (status) => setFocusedStatus(status as TaskStatus | null),
|
||||
groupedTasks,
|
||||
filteredTasks,
|
||||
allTaskStatuses,
|
||||
});
|
||||
|
||||
return (
|
||||
<KanbanProvider onDragEnd={onDragEnd}>
|
||||
{Object.entries(groupedTasks).map(([status, statusTasks]) => (
|
||||
@@ -154,8 +52,7 @@ function TaskKanbanBoard({
|
||||
onDelete={onDeleteTask}
|
||||
onDuplicate={onDuplicateTask}
|
||||
onViewDetails={onViewTaskDetails}
|
||||
isFocused={focusedTaskId === task.id}
|
||||
tabIndex={focusedTaskId === task.id ? 0 : -1}
|
||||
isOpen={selectedTask?.id === task.id}
|
||||
/>
|
||||
))}
|
||||
</KanbanCards>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAttemptCreation } from '@/hooks/useAttemptCreation';
|
||||
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
||||
import BranchSelector from '@/components/tasks/BranchSelector.tsx';
|
||||
import { ExecutorProfileSelector } from '@/components/settings';
|
||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
|
||||
|
||||
import { showModal } from '@/lib/modals';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -94,22 +94,6 @@ function CreateAttempt({
|
||||
[task.status, actuallyCreateAttempt, setIsInCreateAttemptMode]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onEnter: () => {
|
||||
if (!selectedProfile) {
|
||||
return;
|
||||
}
|
||||
onCreateNewAttempt(
|
||||
selectedProfile,
|
||||
createAttemptBranch || undefined,
|
||||
true
|
||||
);
|
||||
},
|
||||
hasOpenDialog: false,
|
||||
closeDialog: () => {},
|
||||
});
|
||||
|
||||
const handleExitCreateAttemptMode = () => {
|
||||
setIsInCreateAttemptMode(false);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ import type { GitOperationError } from 'shared/types';
|
||||
import { displayConflictOpLabel } from '@/lib/conflicts';
|
||||
import { usePush } from '@/hooks/usePush';
|
||||
import { useUserSystem } from '@/components/config-provider.tsx';
|
||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
|
||||
|
||||
import { writeClipboardViaBridge } from '@/vscode/bridge';
|
||||
import { useProcessSelection } from '@/contexts/ProcessSelectionContext';
|
||||
import { openTaskForm } from '@/lib/openTaskForm';
|
||||
@@ -159,32 +159,6 @@ function CurrentAttempt({
|
||||
|
||||
// Use the stopExecution function from the hook
|
||||
|
||||
useKeyboardShortcuts({
|
||||
stopExecution: async () => {
|
||||
try {
|
||||
const result = await showModal<'confirmed' | 'canceled'>(
|
||||
'stop-execution-confirm',
|
||||
{
|
||||
title: 'Stop Current Attempt?',
|
||||
message:
|
||||
'Are you sure you want to stop the current execution? This action cannot be undone.',
|
||||
isExecuting: isStopping,
|
||||
}
|
||||
);
|
||||
|
||||
if (result === 'confirmed') {
|
||||
stopExecution();
|
||||
}
|
||||
} catch (error) {
|
||||
// User cancelled - do nothing
|
||||
}
|
||||
},
|
||||
newAttempt: !isAttemptRunning ? handleEnterCreateAttemptMode : () => {},
|
||||
hasOpenDialog: false,
|
||||
closeDialog: () => {},
|
||||
onEnter: () => {},
|
||||
});
|
||||
|
||||
const handleAttemptChange = useCallback(
|
||||
(attempt: TaskAttempt) => {
|
||||
setSelectedAttempt(attempt);
|
||||
|
||||
@@ -7,19 +7,22 @@ type Props = {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<Element>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<Element>) => void;
|
||||
disabled: boolean;
|
||||
// Loading overlay
|
||||
showLoadingOverlay: boolean;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
};
|
||||
|
||||
export function FollowUpEditorCard({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
showLoadingOverlay,
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
}: Props) {
|
||||
const { projectId } = useProject();
|
||||
return (
|
||||
@@ -28,12 +31,13 @@ export function FollowUpEditorCard({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn('flex-1 min-h-[40px] resize-none')}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
rows={1}
|
||||
maxRows={6}
|
||||
onCommandEnter={onCommandEnter}
|
||||
onCommandShiftEnter={onCommandShiftEnter}
|
||||
/>
|
||||
{showLoadingOverlay && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60">
|
||||
|
||||
@@ -3,67 +3,90 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> {
|
||||
maxRows?: number;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
const AutoExpandingTextarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
AutoExpandingTextareaProps
|
||||
>(({ className, maxRows = 10, ...props }, ref) => {
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
>(
|
||||
(
|
||||
{ className, maxRows = 10, onCommandEnter, onCommandShiftEnter, ...props },
|
||||
ref
|
||||
) => {
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Get the actual ref to use
|
||||
const textareaRef = ref || internalRef;
|
||||
// Get the actual ref to use
|
||||
const textareaRef = ref || internalRef;
|
||||
|
||||
const adjustHeight = React.useCallback(() => {
|
||||
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
|
||||
.current;
|
||||
if (!textarea) return;
|
||||
const adjustHeight = React.useCallback(() => {
|
||||
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
|
||||
.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get the natural height
|
||||
textarea.style.height = 'auto';
|
||||
// Reset height to auto to get the natural height
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate line height
|
||||
const style = window.getComputedStyle(textarea);
|
||||
const lineHeight = parseInt(style.lineHeight) || 20;
|
||||
const paddingTop = parseInt(style.paddingTop) || 0;
|
||||
const paddingBottom = parseInt(style.paddingBottom) || 0;
|
||||
// Calculate line height
|
||||
const style = window.getComputedStyle(textarea);
|
||||
const lineHeight = parseInt(style.lineHeight) || 20;
|
||||
const paddingTop = parseInt(style.paddingTop) || 0;
|
||||
const paddingBottom = parseInt(style.paddingBottom) || 0;
|
||||
|
||||
// Calculate max height based on maxRows
|
||||
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
|
||||
// Calculate max height based on maxRows
|
||||
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
|
||||
|
||||
// Set the height to scrollHeight, but cap at maxHeight
|
||||
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [maxRows]);
|
||||
// Set the height to scrollHeight, but cap at maxHeight
|
||||
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [maxRows]);
|
||||
|
||||
// Adjust height on mount and when content changes
|
||||
React.useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [adjustHeight, props.value]);
|
||||
|
||||
// Adjust height on input
|
||||
const handleInput = React.useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
// Adjust height on mount and when content changes
|
||||
React.useEffect(() => {
|
||||
adjustHeight();
|
||||
if (props.onInput) {
|
||||
props.onInput(e);
|
||||
}
|
||||
},
|
||||
[adjustHeight, props.onInput]
|
||||
);
|
||||
}, [adjustHeight, props.value]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words',
|
||||
className
|
||||
)}
|
||||
ref={textareaRef}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.metaKey && e.shiftKey) {
|
||||
onCommandShiftEnter?.(e);
|
||||
} else if (e.metaKey) {
|
||||
onCommandEnter?.(e);
|
||||
}
|
||||
}
|
||||
props.onKeyDown?.(e);
|
||||
},
|
||||
[onCommandEnter, onCommandShiftEnter, props.onKeyDown]
|
||||
);
|
||||
|
||||
// Adjust height on input
|
||||
const handleInput = React.useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
adjustHeight();
|
||||
if (props.onInput) {
|
||||
props.onInput(e);
|
||||
}
|
||||
},
|
||||
[adjustHeight, props.onInput]
|
||||
);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words',
|
||||
className
|
||||
)}
|
||||
ref={textareaRef}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AutoExpandingTextarea.displayName = 'AutoExpandingTextarea';
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDialogKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
import { useKeyExit, useKeySubmit, Scope } from '@/keyboard';
|
||||
|
||||
const Dialog = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -12,12 +13,92 @@ const Dialog = React.forwardRef<
|
||||
uncloseable?: boolean;
|
||||
}
|
||||
>(({ className, open, onOpenChange, children, uncloseable, ...props }, ref) => {
|
||||
// Add keyboard shortcut support for closing dialog with Esc
|
||||
useDialogKeyboardShortcuts(() => {
|
||||
if (open && onOpenChange && !uncloseable) {
|
||||
onOpenChange(false);
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
|
||||
// Manage dialog scope when open/closed
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
enableScope(Scope.DIALOG);
|
||||
disableScope(Scope.KANBAN);
|
||||
disableScope(Scope.PROJECTS);
|
||||
} else {
|
||||
disableScope(Scope.DIALOG);
|
||||
enableScope(Scope.KANBAN);
|
||||
enableScope(Scope.PROJECTS);
|
||||
}
|
||||
});
|
||||
}, [open, enableScope, disableScope]);
|
||||
|
||||
// Dialog keyboard shortcuts using semantic hooks
|
||||
useKeyExit(
|
||||
(e) => {
|
||||
if (uncloseable) return;
|
||||
|
||||
// Two-step Esc behavior:
|
||||
// 1. If input/textarea is focused, blur it first
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (
|
||||
activeElement &&
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.isContentEditable)
|
||||
) {
|
||||
activeElement.blur();
|
||||
e?.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Otherwise close the dialog
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
{
|
||||
scope: Scope.DIALOG,
|
||||
when: () => !!open,
|
||||
}
|
||||
);
|
||||
|
||||
useKeySubmit(
|
||||
(e) => {
|
||||
// Don't interfere if user is typing in textarea (allow new lines)
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement?.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look for submit button or primary action button within this dialog
|
||||
if (ref && typeof ref === 'object' && ref.current) {
|
||||
// First try to find a submit button
|
||||
const submitButton = ref.current.querySelector(
|
||||
'button[type="submit"]'
|
||||
) as HTMLButtonElement;
|
||||
if (submitButton && !submitButton.disabled) {
|
||||
e?.preventDefault();
|
||||
submitButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no submit button, look for primary action button
|
||||
const buttons = Array.from(
|
||||
ref.current.querySelectorAll('button')
|
||||
) as HTMLButtonElement[];
|
||||
const primaryButton = buttons.find(
|
||||
(btn) =>
|
||||
!btn.disabled &&
|
||||
!btn.textContent?.toLowerCase().includes('cancel') &&
|
||||
!btn.textContent?.toLowerCase().includes('close') &&
|
||||
btn.type !== 'button'
|
||||
);
|
||||
|
||||
if (primaryButton) {
|
||||
e?.preventDefault();
|
||||
primaryButton.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
scope: Scope.DIALOG,
|
||||
when: () => !!open,
|
||||
}
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
|
||||
import { projectsApi } from '@/lib/api';
|
||||
@@ -19,6 +19,8 @@ interface FileSearchTextareaProps {
|
||||
projectId?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
maxRows?: number;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
export function FileSearchTextarea({
|
||||
@@ -29,7 +31,8 @@ export function FileSearchTextarea({
|
||||
disabled = false,
|
||||
className,
|
||||
projectId,
|
||||
onKeyDown,
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
maxRows = 10,
|
||||
}: FileSearchTextareaProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -105,41 +108,6 @@ export function FileSearchTextarea({
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle dropdown navigation first
|
||||
if (showDropdown && searchResults.length > 0) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < searchResults.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : searchResults.length - 1
|
||||
);
|
||||
return;
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectFile(searchResults[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
setSearchQuery('');
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the passed onKeyDown handler
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
|
||||
// Select a file and insert it into the text
|
||||
const selectFile = (file: FileSearchResult) => {
|
||||
@@ -234,6 +202,46 @@ export function FileSearchTextarea({
|
||||
|
||||
const dropdownPosition = getDropdownPosition();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle dropdown navigation first
|
||||
if (showDropdown && searchResults.length > 0) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < searchResults.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : searchResults.length - 1
|
||||
);
|
||||
return;
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectFile(searchResults[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
setSearchQuery('');
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
textareaRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className?.includes('flex-1') ? 'flex-1' : ''}`}
|
||||
@@ -242,12 +250,14 @@ export function FileSearchTextarea({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
maxRows={maxRows}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCommandEnter={onCommandEnter}
|
||||
onCommandShiftEnter={onCommandShiftEnter}
|
||||
/>
|
||||
|
||||
{showDropdown &&
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
onKeyDown,
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (e.metaKey && e.shiftKey) {
|
||||
onCommandShiftEnter?.(e);
|
||||
} else {
|
||||
onCommandEnter?.(e);
|
||||
}
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'flex h-10 w-full border px-3 py-2 text-sm ring-offset-background file:border-0 bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
Input.displayName = 'Input';
|
||||
export { Input };
|
||||
|
||||
@@ -59,7 +59,7 @@ export const JSONEditor: React.FC<JSONEditorProps> = ({
|
||||
autocompletion: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
searchKeymap: true,
|
||||
searchKeymap: false,
|
||||
}}
|
||||
extensions={[
|
||||
json(),
|
||||
|
||||
@@ -63,6 +63,7 @@ export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
|
||||
tabIndex?: number;
|
||||
forwardedRef?: Ref<HTMLDivElement>;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
export const KanbanCard = ({
|
||||
@@ -76,6 +77,7 @@ export const KanbanCard = ({
|
||||
tabIndex,
|
||||
forwardedRef,
|
||||
onKeyDown,
|
||||
isOpen,
|
||||
}: KanbanCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
@@ -97,8 +99,9 @@ export const KanbanCard = ({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'p-3 focus:ring-2 ring-secondary-foreground outline-none border-b flex-col space-y-2',
|
||||
'p-3 outline-none border-b flex-col space-y-2',
|
||||
isDragging && 'cursor-grabbing',
|
||||
isOpen && 'ring-2 ring-secondary-foreground ring-inset',
|
||||
className
|
||||
)}
|
||||
{...listeners}
|
||||
|
||||
126
frontend/src/contexts/keyboard-shortcuts-context.tsx
Normal file
126
frontend/src/contexts/keyboard-shortcuts-context.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
export interface ShortcutConfig {
|
||||
keys: string | string[]; // 'c' or ['cmd+k', 'ctrl+k']
|
||||
callback: (e: KeyboardEvent) => void;
|
||||
description: string; // For help documentation
|
||||
group?: string; // 'Dialog', 'Kanban', 'Global'
|
||||
scope?: string; // 'global', 'kanban', 'dialog'
|
||||
when?: boolean | (() => boolean); // Dynamic enabling
|
||||
}
|
||||
|
||||
export interface RegisteredShortcut extends ShortcutConfig {
|
||||
id: string; // Auto-generated unique ID
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
shortcuts: RegisteredShortcut[];
|
||||
register: (config: ShortcutConfig) => () => void; // Returns unregister function
|
||||
getShortcutsByScope: (scope?: string) => RegisteredShortcut[];
|
||||
getShortcutsByGroup: (group?: string) => RegisteredShortcut[];
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsState | null>(
|
||||
null
|
||||
);
|
||||
|
||||
interface KeyboardShortcutsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsProvider({
|
||||
children,
|
||||
}: KeyboardShortcutsProviderProps) {
|
||||
const [shortcuts, setShortcuts] = useState<RegisteredShortcut[]>([]);
|
||||
const idCounter = useRef(0);
|
||||
const shortcutsRef = useRef<RegisteredShortcut[]>([]);
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
shortcutsRef.current = shortcuts;
|
||||
}, [shortcuts]);
|
||||
|
||||
const register = useCallback(
|
||||
(config: ShortcutConfig) => {
|
||||
const id = `shortcut-${idCounter.current++}`;
|
||||
const registeredShortcut: RegisteredShortcut = { ...config, id };
|
||||
|
||||
// Development-only conflict detection using ref to avoid dependency cycle
|
||||
if (import.meta.env.DEV) {
|
||||
const conflictingShortcut = shortcutsRef.current.find((existing) => {
|
||||
const sameScope =
|
||||
(existing.scope || 'global') === (config.scope || 'global');
|
||||
const sameKeys =
|
||||
JSON.stringify(existing.keys) === JSON.stringify(config.keys);
|
||||
return sameScope && sameKeys;
|
||||
});
|
||||
|
||||
if (conflictingShortcut) {
|
||||
console.warn(
|
||||
`Keyboard shortcut conflict detected!`,
|
||||
`\nExisting: ${conflictingShortcut.description} (${conflictingShortcut.keys})`,
|
||||
`\nNew: ${config.description} (${config.keys})`,
|
||||
`\nScope: ${config.scope || 'global'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setShortcuts((prev) => [...prev, registeredShortcut]);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
setShortcuts((prev) => prev.filter((shortcut) => shortcut.id !== id));
|
||||
};
|
||||
},
|
||||
[] // Empty dependencies - function stays stable
|
||||
);
|
||||
|
||||
const getShortcutsByScope = useCallback(
|
||||
(scope?: string) => {
|
||||
const targetScope = scope || 'global';
|
||||
return shortcuts.filter(
|
||||
(shortcut) => (shortcut.scope || 'global') === targetScope
|
||||
);
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
const getShortcutsByGroup = useCallback(
|
||||
(group?: string) => {
|
||||
if (!group) return shortcuts;
|
||||
return shortcuts.filter((shortcut) => shortcut.group === group);
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
const value: KeyboardShortcutsState = {
|
||||
shortcuts,
|
||||
register,
|
||||
getShortcutsByScope,
|
||||
getShortcutsByGroup,
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={value}>
|
||||
{children}
|
||||
</KeyboardShortcutsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutsRegistry(): KeyboardShortcutsState {
|
||||
const context = useContext(KeyboardShortcutsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useKeyboardShortcutsRegistry must be used within a KeyboardShortcutsProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
@@ -12,6 +14,8 @@ interface SearchState {
|
||||
setQuery: (query: string) => void;
|
||||
active: boolean;
|
||||
clear: () => void;
|
||||
focusInput: () => void;
|
||||
registerInputRef: (ref: HTMLInputElement | null) => void;
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchState | null>(null);
|
||||
@@ -24,6 +28,7 @@ export function SearchProvider({ children }: SearchProviderProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const location = useLocation();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Check if we're on a tasks route
|
||||
const isTasksRoute = /^\/projects\/[^/]+\/tasks/.test(location.pathname);
|
||||
@@ -42,11 +47,23 @@ export function SearchProvider({ children }: SearchProviderProps) {
|
||||
|
||||
const clear = () => setQuery('');
|
||||
|
||||
const focusInput = () => {
|
||||
if (inputRef.current && isTasksRoute) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const registerInputRef = useCallback((ref: HTMLInputElement | null) => {
|
||||
inputRef.current = ref;
|
||||
}, []);
|
||||
|
||||
const value: SearchState = {
|
||||
query,
|
||||
setQuery,
|
||||
active: isTasksRoute,
|
||||
clear,
|
||||
focusInput,
|
||||
registerInputRef,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ExecutionProcess,
|
||||
ExecutorProfileId,
|
||||
} from 'shared/types';
|
||||
import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts';
|
||||
|
||||
type Args = {
|
||||
processes: ExecutionProcess[];
|
||||
@@ -59,11 +58,5 @@ export function useDefaultVariant({ processes, profiles }: Args) {
|
||||
return profiles?.[latestProfileId.executor] ?? null;
|
||||
}, [latestProfileId, profiles]);
|
||||
|
||||
useVariantCyclingShortcut({
|
||||
currentProfile,
|
||||
selectedVariant,
|
||||
setSelectedVariant,
|
||||
});
|
||||
|
||||
return { selectedVariant, setSelectedVariant, currentProfile } as const;
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export { useCreatePR } from './useCreatePR';
|
||||
export { useMerge } from './useMerge';
|
||||
export { usePush } from './usePush';
|
||||
export { useProjectBranches } from './useProjectBranches';
|
||||
export { useKeyboardShortcut } from './useKeyboardShortcut';
|
||||
|
||||
72
frontend/src/hooks/useKeyboardShortcut.ts
Normal file
72
frontend/src/hooks/useKeyboardShortcut.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
useKeyboardShortcutsRegistry,
|
||||
type ShortcutConfig,
|
||||
} from '@/contexts/keyboard-shortcuts-context';
|
||||
|
||||
export interface KeyboardShortcutOptions {
|
||||
enableOnContentEditable?: boolean;
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcut(
|
||||
config: ShortcutConfig,
|
||||
options: KeyboardShortcutOptions = {}
|
||||
): void {
|
||||
const { register } = useKeyboardShortcutsRegistry();
|
||||
const unregisterRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const { keys, callback, when = true, description, group, scope } = config;
|
||||
const { enableOnContentEditable = false, preventDefault = false } = options;
|
||||
|
||||
// Keep latest callback/when without forcing re-register
|
||||
const callbackRef = useRef(callback);
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
const whenRef = useRef(when);
|
||||
useEffect(() => {
|
||||
whenRef.current = when;
|
||||
}, [when]);
|
||||
|
||||
// Register once per identity fields (no direct 'config' usage here)
|
||||
useEffect(() => {
|
||||
const unregister = register({
|
||||
keys,
|
||||
description,
|
||||
group,
|
||||
scope,
|
||||
// delegate to latest refs
|
||||
callback: (e: KeyboardEvent) => callbackRef.current?.(e as KeyboardEvent),
|
||||
when: () => {
|
||||
const w = whenRef.current;
|
||||
return typeof w === 'function' ? !!w() : !!w;
|
||||
},
|
||||
});
|
||||
unregisterRef.current = unregister;
|
||||
|
||||
return () => {
|
||||
unregisterRef.current?.();
|
||||
unregisterRef.current = null;
|
||||
};
|
||||
}, [register, keys, description, group, scope]);
|
||||
|
||||
// Bind the actual keyboard handling
|
||||
useHotkeys(
|
||||
keys,
|
||||
(event) => {
|
||||
const w = whenRef.current;
|
||||
const enabled = typeof w === 'function' ? !!w() : !!w;
|
||||
if (enabled) callbackRef.current?.(event as KeyboardEvent);
|
||||
},
|
||||
{
|
||||
enabled: true, // we gate inside handler via whenRef
|
||||
enableOnContentEditable,
|
||||
preventDefault,
|
||||
scopes: scope ? [scope] : ['*'],
|
||||
},
|
||||
[keys, scope] // handler uses refs; only rebinding when identity changes
|
||||
);
|
||||
}
|
||||
@@ -7,28 +7,34 @@ import { SUPPORTED_I18N_CODES, uiLanguageToI18nCode } from './languages';
|
||||
import enCommon from './locales/en/common.json';
|
||||
import enSettings from './locales/en/settings.json';
|
||||
import enProjects from './locales/en/projects.json';
|
||||
import enTasks from './locales/en/tasks.json';
|
||||
import jaCommon from './locales/ja/common.json';
|
||||
import jaSettings from './locales/ja/settings.json';
|
||||
import jaProjects from './locales/ja/projects.json';
|
||||
import jaTasks from './locales/ja/tasks.json';
|
||||
import esCommon from './locales/es/common.json';
|
||||
import esSettings from './locales/es/settings.json';
|
||||
import esProjects from './locales/es/projects.json';
|
||||
import esTasks from './locales/es/tasks.json';
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
common: enCommon,
|
||||
settings: enSettings,
|
||||
projects: enProjects,
|
||||
tasks: enTasks,
|
||||
},
|
||||
ja: {
|
||||
common: jaCommon,
|
||||
settings: jaSettings,
|
||||
projects: jaProjects,
|
||||
tasks: jaTasks,
|
||||
},
|
||||
es: {
|
||||
common: esCommon,
|
||||
settings: esSettings,
|
||||
projects: esProjects,
|
||||
tasks: esTasks,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
"success": "Success",
|
||||
"reconnecting": "Reconnecting"
|
||||
},
|
||||
"language": {
|
||||
"browserDefault": "Browser Default"
|
||||
|
||||
8
frontend/src/i18n/locales/en/tasks.json
Normal file
8
frontend/src/i18n/locales/en/tasks.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"loading": "Loading tasks...",
|
||||
"empty": {
|
||||
"noTasks": "No tasks found for this project.",
|
||||
"createFirst": "Create First Task",
|
||||
"noSearchResults": "No tasks match your search."
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"loading": "Cargando...",
|
||||
"saving": "Guardando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito"
|
||||
"success": "Éxito",
|
||||
"reconnecting": "Reconectando"
|
||||
},
|
||||
"language": {
|
||||
"browserDefault": "Predeterminado del navegador"
|
||||
|
||||
8
frontend/src/i18n/locales/es/tasks.json
Normal file
8
frontend/src/i18n/locales/es/tasks.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"loading": "Cargando tareas...",
|
||||
"empty": {
|
||||
"noTasks": "No se encontraron tareas para este proyecto.",
|
||||
"createFirst": "Crear Primera Tarea",
|
||||
"noSearchResults": "Ninguna tarea coincide con tu búsqueda."
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"loading": "読み込み中...",
|
||||
"saving": "保存中...",
|
||||
"error": "エラー",
|
||||
"success": "成功"
|
||||
"success": "成功",
|
||||
"reconnecting": "再接続中"
|
||||
},
|
||||
"language": {
|
||||
"browserDefault": "ブラウザ設定"
|
||||
|
||||
8
frontend/src/i18n/locales/ja/tasks.json
Normal file
8
frontend/src/i18n/locales/ja/tasks.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"loading": "タスクを読み込み中...",
|
||||
"empty": {
|
||||
"noTasks": "このプロジェクトにタスクが見つかりません。",
|
||||
"createFirst": "最初のタスクを作成",
|
||||
"noSearchResults": "検索条件に一致するタスクがありません。"
|
||||
}
|
||||
}
|
||||
86
frontend/src/keyboard/hooks.ts
Normal file
86
frontend/src/keyboard/hooks.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createSemanticHook } from './useSemanticKey';
|
||||
import { Action } from './registry';
|
||||
|
||||
/**
|
||||
* Semantic keyboard shortcut hooks
|
||||
*
|
||||
* These hooks provide a clean, semantic interface for common keyboard actions.
|
||||
* All key bindings are centrally managed in the registry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Exit/Close action - typically Esc key
|
||||
*
|
||||
* @example
|
||||
* // In a dialog
|
||||
* useKeyExit(() => closeDialog(), { scope: Scope.DIALOG });
|
||||
*
|
||||
* @example
|
||||
* // In kanban board
|
||||
* useKeyExit(() => navigateToProjects(), { scope: Scope.KANBAN });
|
||||
*/
|
||||
export const useKeyExit = createSemanticHook(Action.EXIT);
|
||||
|
||||
/**
|
||||
* Create action - typically 'c' key
|
||||
*
|
||||
* @example
|
||||
* // Create new task
|
||||
* useKeyCreate(() => openTaskForm(), { scope: Scope.KANBAN });
|
||||
*
|
||||
* @example
|
||||
* // Create new project
|
||||
* useKeyCreate(() => openProjectForm(), { scope: Scope.PROJECTS });
|
||||
*/
|
||||
export const useKeyCreate = createSemanticHook(Action.CREATE);
|
||||
|
||||
/**
|
||||
* Submit action - typically Enter key
|
||||
*
|
||||
* @example
|
||||
* // Submit form in dialog
|
||||
* useKeySubmit(() => submitForm(), { scope: Scope.DIALOG });
|
||||
*/
|
||||
export const useKeySubmit = createSemanticHook(Action.SUBMIT);
|
||||
|
||||
/**
|
||||
* Focus search action - typically '/' key
|
||||
*
|
||||
* @example
|
||||
* useKeyFocusSearch(() => focusSearchInput(), { scope: Scope.KANBAN });
|
||||
*/
|
||||
export const useKeyFocusSearch = createSemanticHook(Action.FOCUS_SEARCH);
|
||||
|
||||
/**
|
||||
* Navigation actions - arrow keys
|
||||
*/
|
||||
export const useKeyNavUp = createSemanticHook(Action.NAV_UP);
|
||||
export const useKeyNavDown = createSemanticHook(Action.NAV_DOWN);
|
||||
export const useKeyNavLeft = createSemanticHook(Action.NAV_LEFT);
|
||||
export const useKeyNavRight = createSemanticHook(Action.NAV_RIGHT);
|
||||
|
||||
/**
|
||||
* Open details action - typically Enter key
|
||||
*
|
||||
* @example
|
||||
* useKeyOpenDetails(() => openTaskDetails(), { scope: Scope.KANBAN });
|
||||
*/
|
||||
export const useKeyOpenDetails = createSemanticHook(Action.OPEN_DETAILS);
|
||||
|
||||
/**
|
||||
* Show help action - typically '?' key
|
||||
*
|
||||
* @example
|
||||
* useKeyShowHelp(() => openHelpDialog(), { scope: Scope.GLOBAL });
|
||||
*/
|
||||
export const useKeyShowHelp = createSemanticHook(Action.SHOW_HELP);
|
||||
|
||||
/**
|
||||
* Toggle fullscreen action - typically Cmd+Enter key
|
||||
*
|
||||
* @example
|
||||
* useKeyToggleFullscreen(() => toggleFullscreen(), { scope: Scope.TASK_PANEL });
|
||||
*/
|
||||
export const useKeyToggleFullscreen = createSemanticHook(
|
||||
Action.TOGGLE_FULLSCREEN
|
||||
);
|
||||
6
frontend/src/keyboard/index.ts
Normal file
6
frontend/src/keyboard/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all semantic keyboard hooks
|
||||
export * from './hooks';
|
||||
export * from './registry';
|
||||
|
||||
// Re-export the raw hook for edge cases
|
||||
export { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut';
|
||||
172
frontend/src/keyboard/registry.ts
Normal file
172
frontend/src/keyboard/registry.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export enum Scope {
|
||||
GLOBAL = 'global',
|
||||
DIALOG = 'dialog',
|
||||
KANBAN = 'kanban',
|
||||
PROJECTS = 'projects',
|
||||
EDIT_COMMENT = 'edit-comment',
|
||||
}
|
||||
|
||||
export enum Action {
|
||||
EXIT = 'exit',
|
||||
CREATE = 'create',
|
||||
SUBMIT = 'submit',
|
||||
FOCUS_SEARCH = 'focus_search',
|
||||
NAV_UP = 'nav_up',
|
||||
NAV_DOWN = 'nav_down',
|
||||
NAV_LEFT = 'nav_left',
|
||||
NAV_RIGHT = 'nav_right',
|
||||
OPEN_DETAILS = 'open_details',
|
||||
SHOW_HELP = 'show_help',
|
||||
TOGGLE_FULLSCREEN = 'toggle_fullscreen',
|
||||
}
|
||||
|
||||
export interface KeyBinding {
|
||||
action: Action;
|
||||
keys: string | string[];
|
||||
scopes?: Scope[];
|
||||
description: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const keyBindings: KeyBinding[] = [
|
||||
// Exit/Close actions
|
||||
{
|
||||
action: Action.EXIT,
|
||||
keys: 'esc',
|
||||
scopes: [Scope.DIALOG],
|
||||
description: 'Close dialog or blur input',
|
||||
group: 'Dialog',
|
||||
},
|
||||
{
|
||||
action: Action.EXIT,
|
||||
keys: 'esc',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Close panel or navigate to projects',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.EXIT,
|
||||
keys: 'esc',
|
||||
scopes: [Scope.EDIT_COMMENT],
|
||||
description: 'Cancel comment',
|
||||
group: 'Comments',
|
||||
},
|
||||
|
||||
// Creation actions
|
||||
{
|
||||
action: Action.CREATE,
|
||||
keys: 'c',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Create new task',
|
||||
group: 'Kanban',
|
||||
},
|
||||
{
|
||||
action: Action.CREATE,
|
||||
keys: 'c',
|
||||
scopes: [Scope.PROJECTS],
|
||||
description: 'Create new project',
|
||||
group: 'Projects',
|
||||
},
|
||||
|
||||
// Submit actions
|
||||
{
|
||||
action: Action.SUBMIT,
|
||||
keys: 'enter',
|
||||
scopes: [Scope.DIALOG],
|
||||
description: 'Submit form or confirm action',
|
||||
group: 'Dialog',
|
||||
},
|
||||
|
||||
// Navigation actions
|
||||
{
|
||||
action: Action.FOCUS_SEARCH,
|
||||
keys: 'slash',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Focus search',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.NAV_UP,
|
||||
keys: 'up',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Move up within column',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.NAV_DOWN,
|
||||
keys: 'down',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Move down within column',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.NAV_LEFT,
|
||||
keys: 'left',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Move to previous column',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.NAV_RIGHT,
|
||||
keys: 'right',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Move to next column',
|
||||
group: 'Navigation',
|
||||
},
|
||||
{
|
||||
action: Action.OPEN_DETAILS,
|
||||
keys: 'enter',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Open selected task details',
|
||||
group: 'Kanban',
|
||||
},
|
||||
|
||||
// Global actions
|
||||
{
|
||||
action: Action.SHOW_HELP,
|
||||
keys: 'shift+slash',
|
||||
scopes: [Scope.GLOBAL],
|
||||
description: 'Show keyboard shortcuts help',
|
||||
group: 'Global',
|
||||
},
|
||||
|
||||
// Task panel actions
|
||||
{
|
||||
action: Action.TOGGLE_FULLSCREEN,
|
||||
keys: 'enter',
|
||||
scopes: [Scope.KANBAN],
|
||||
description: 'Toggle fullscreen view',
|
||||
group: 'Task Details',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get keyboard bindings for a specific action and scope
|
||||
*/
|
||||
export function getKeysFor(action: Action, scope?: Scope): string[] {
|
||||
const bindings = keyBindings
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.action === action &&
|
||||
(!scope || !binding.scopes || binding.scopes.includes(scope))
|
||||
)
|
||||
.flatMap((binding) =>
|
||||
Array.isArray(binding.keys) ? binding.keys : [binding.keys]
|
||||
);
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binding info for a specific action and scope
|
||||
*/
|
||||
export function getBindingFor(
|
||||
action: Action,
|
||||
scope?: Scope
|
||||
): KeyBinding | undefined {
|
||||
return keyBindings.find(
|
||||
(binding) =>
|
||||
binding.action === action &&
|
||||
(!scope || !binding.scopes || binding.scopes.includes(scope))
|
||||
);
|
||||
}
|
||||
63
frontend/src/keyboard/useSemanticKey.ts
Normal file
63
frontend/src/keyboard/useSemanticKey.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
useKeyboardShortcut,
|
||||
type KeyboardShortcutOptions,
|
||||
} from '@/hooks/useKeyboardShortcut';
|
||||
import { Action, Scope, getKeysFor, getBindingFor } from './registry';
|
||||
|
||||
export interface SemanticKeyOptions {
|
||||
scope?: Scope;
|
||||
enabled?: boolean | (() => boolean);
|
||||
when?: boolean | (() => boolean); // Alias for enabled
|
||||
enableOnContentEditable?: boolean;
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
|
||||
type Handler = (e?: KeyboardEvent) => void;
|
||||
|
||||
/**
|
||||
* Creates a semantic keyboard shortcut hook for a specific action
|
||||
*/
|
||||
export function createSemanticHook<A extends Action>(action: A) {
|
||||
return function useSemanticKey(
|
||||
handler: Handler,
|
||||
options: SemanticKeyOptions = {}
|
||||
) {
|
||||
const {
|
||||
scope,
|
||||
enabled = true,
|
||||
when,
|
||||
enableOnContentEditable,
|
||||
preventDefault,
|
||||
} = options;
|
||||
|
||||
// Use 'when' as alias for 'enabled' if provided
|
||||
const isEnabled = when !== undefined ? when : enabled;
|
||||
|
||||
const keys = getKeysFor(action, scope);
|
||||
const binding = getBindingFor(action, scope);
|
||||
|
||||
const keyboardShortcutOptions: KeyboardShortcutOptions = {};
|
||||
if (enableOnContentEditable !== undefined)
|
||||
keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable;
|
||||
if (preventDefault !== undefined)
|
||||
keyboardShortcutOptions.preventDefault = preventDefault;
|
||||
|
||||
useKeyboardShortcut(
|
||||
{
|
||||
keys: keys.length === 1 ? keys[0] : keys,
|
||||
callback: keys.length === 0 ? () => {} : handler,
|
||||
description: binding?.description || `${action} action`,
|
||||
group: binding?.group || 'Actions',
|
||||
scope: scope || Scope.GLOBAL,
|
||||
when: keys.length > 0 && isEnabled,
|
||||
},
|
||||
keyboardShortcutOptions
|
||||
);
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.warn(
|
||||
`No key binding found for action ${action} in scope ${scope}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { ExecutorConfig } from 'shared/types';
|
||||
|
||||
// Define available keyboard shortcuts
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: (context?: KeyboardShortcutContext) => void;
|
||||
requiresModifier?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutContext {
|
||||
navigate?: ReturnType<typeof useNavigate>;
|
||||
closeDialog?: () => void;
|
||||
onC?: () => void;
|
||||
currentPath?: string;
|
||||
hasOpenDialog?: boolean;
|
||||
location?: ReturnType<typeof useLocation>;
|
||||
stopExecution?: () => void;
|
||||
newAttempt?: () => void;
|
||||
onEnter?: () => void;
|
||||
ignoreEscape?: boolean;
|
||||
}
|
||||
|
||||
// Centralized shortcut definitions
|
||||
export const createKeyboardShortcuts = (
|
||||
context: KeyboardShortcutContext
|
||||
): Record<string, KeyboardShortcut> => ({
|
||||
Escape: {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, navigate back
|
||||
if (context.navigate) {
|
||||
const currentPath =
|
||||
context.currentPath || context.location?.pathname || '/';
|
||||
|
||||
// Navigate back based on current path
|
||||
if (
|
||||
currentPath.includes('/tasks/') &&
|
||||
!currentPath.endsWith('/tasks')
|
||||
) {
|
||||
// From task details, go back to project tasks
|
||||
const projectPath = currentPath.split('/tasks/')[0] + '/tasks';
|
||||
context.navigate(projectPath);
|
||||
} else if (
|
||||
currentPath.includes('/projects/') &&
|
||||
currentPath.includes('/tasks')
|
||||
) {
|
||||
// From project tasks, go back to projects
|
||||
context.navigate('/projects');
|
||||
} else if (currentPath !== '/' && currentPath !== '/projects') {
|
||||
// Default: go to projects page
|
||||
context.navigate('/projects');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
Enter: {
|
||||
key: 'Enter',
|
||||
description: 'Enter or submit',
|
||||
action: () => {
|
||||
if (context.onEnter) {
|
||||
context.onEnter();
|
||||
}
|
||||
},
|
||||
},
|
||||
KeyC: {
|
||||
key: 'c',
|
||||
description: 'Create new task',
|
||||
action: () => {
|
||||
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
|
||||
export function useKeyboardShortcuts(context: KeyboardShortcutContext) {
|
||||
const shortcuts = createKeyboardShortcuts(context);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in input fields
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't trigger shortcuts when modifier keys are pressed (except for specific shortcuts)
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcuts[event.code] || shortcuts[event.key];
|
||||
|
||||
if (shortcut && !shortcut.disabled) {
|
||||
event.preventDefault();
|
||||
shortcut.action(context);
|
||||
}
|
||||
},
|
||||
[shortcuts, context]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
// Hook for dialog-specific keyboard shortcuts
|
||||
export function useDialogKeyboardShortcuts(onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
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: unknown[];
|
||||
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 === ' ') && onViewTaskDetails) {
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
// Hook for cycling through profile variants with Left Shift + Tab
|
||||
export function useVariantCyclingShortcut({
|
||||
currentProfile,
|
||||
selectedVariant,
|
||||
setSelectedVariant,
|
||||
}: {
|
||||
currentProfile: ExecutorConfig | null | undefined;
|
||||
selectedVariant: string | null;
|
||||
setSelectedVariant: (variant: string | null) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!currentProfile || Object.keys(currentProfile).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Left Shift + Tab
|
||||
if (e.shiftKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
// Build the variant cycle: variant1 → variant2 → ... → variant1
|
||||
const variants = currentProfile;
|
||||
const variantLabels = Object.keys(variants);
|
||||
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = variantLabels.findIndex(
|
||||
(v) => v === selectedVariant
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % variantLabels.length;
|
||||
const nextVariant = variantLabels[nextIndex];
|
||||
|
||||
setSelectedVariant(nextVariant);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentProfile, selectedVariant, setSelectedVariant]);
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AlertTriangle, Plus } from 'lucide-react';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { projectsApi, tasksApi, attemptsApi } from '@/lib/api';
|
||||
import { openTaskForm } from '@/lib/openTaskForm';
|
||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
|
||||
|
||||
import { useSearch } from '@/contexts/search-context';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTaskViewManager } from '@/hooks/useTaskViewManager';
|
||||
import {
|
||||
useKeyCreate,
|
||||
useKeyExit,
|
||||
useKeyFocusSearch,
|
||||
useKeyNavUp,
|
||||
useKeyNavDown,
|
||||
useKeyNavLeft,
|
||||
useKeyNavRight,
|
||||
useKeyOpenDetails,
|
||||
Scope,
|
||||
useKeyToggleFullscreen,
|
||||
} from '@/keyboard';
|
||||
|
||||
import {
|
||||
getKanbanSectionClasses,
|
||||
@@ -23,16 +36,27 @@ import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
||||
import { useProjectTasks } from '@/hooks/useProjectTasks';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
|
||||
type Task = TaskWithAttemptStatus;
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const { projectId, taskId, attemptId } = useParams<{
|
||||
projectId: string;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
|
||||
useEffect(() => {
|
||||
enableScope(Scope.KANBAN);
|
||||
|
||||
return () => {
|
||||
disableScope(Scope.KANBAN);
|
||||
};
|
||||
}, [enableScope, disableScope]);
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,14 +78,14 @@ export function ProjectTasks() {
|
||||
openTaskForm({ projectId: project.id, initialTask: task });
|
||||
}
|
||||
};
|
||||
const { query: searchQuery } = useSearch();
|
||||
const { query: searchQuery, focusInput } = useSearch();
|
||||
|
||||
// Panel state
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
||||
// Fullscreen state using custom hook
|
||||
const { isFullscreen, navigateToTask, navigateToAttempt } =
|
||||
const { isFullscreen, navigateToTask, navigateToAttempt, toggleFullscreen } =
|
||||
useTaskViewManager();
|
||||
|
||||
// Attempts fetching (only when task is selected)
|
||||
@@ -123,6 +147,128 @@ export function ProjectTasks() {
|
||||
handleCreateTask();
|
||||
}, [handleCreateTask]);
|
||||
|
||||
// Semantic keyboard shortcuts for kanban page
|
||||
// Prevent default is needed to stop the input having the value 'c'
|
||||
useKeyCreate(handleCreateNewTask, {
|
||||
scope: Scope.KANBAN,
|
||||
preventDefault: true,
|
||||
});
|
||||
|
||||
useKeyFocusSearch(
|
||||
() => {
|
||||
focusInput();
|
||||
},
|
||||
{
|
||||
scope: Scope.KANBAN,
|
||||
preventDefault: true, // Prevent Firefox quick find
|
||||
}
|
||||
);
|
||||
|
||||
useKeyExit(
|
||||
() => {
|
||||
if (isPanelOpen) {
|
||||
if (isFullscreen) {
|
||||
toggleFullscreen(false);
|
||||
} else {
|
||||
handleClosePanel();
|
||||
}
|
||||
} else {
|
||||
navigate('/projects');
|
||||
}
|
||||
},
|
||||
{ scope: Scope.KANBAN }
|
||||
);
|
||||
|
||||
// Toggle fullscreen with Cmd+Enter
|
||||
useKeyToggleFullscreen(() => toggleFullscreen(!isFullscreen), {
|
||||
scope: Scope.KANBAN,
|
||||
});
|
||||
|
||||
// Navigation shortcuts using semantic hooks
|
||||
const taskStatuses = [
|
||||
'todo',
|
||||
'inprogress',
|
||||
'inreview',
|
||||
'done',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
// Memoize filtered tasks based on search query
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return tasks;
|
||||
}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.title.toLowerCase().includes(query) ||
|
||||
(task.description && task.description.toLowerCase().includes(query))
|
||||
);
|
||||
}, [tasks, searchQuery]);
|
||||
|
||||
// Memoize grouped filtered tasks
|
||||
const groupedFilteredTasks = useMemo(() => {
|
||||
const groups: Record<string, Task[]> = {};
|
||||
taskStatuses.forEach((status) => {
|
||||
groups[status] = [];
|
||||
});
|
||||
filteredTasks.forEach((task) => {
|
||||
const normalizedStatus = task.status.toLowerCase();
|
||||
if (groups[normalizedStatus]) {
|
||||
groups[normalizedStatus].push(task);
|
||||
} else {
|
||||
groups['todo'].push(task);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}, [filteredTasks]);
|
||||
|
||||
useKeyNavUp(
|
||||
() => {
|
||||
selectPreviousTask();
|
||||
},
|
||||
{
|
||||
scope: Scope.KANBAN,
|
||||
when: !isFullscreen,
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
|
||||
useKeyNavDown(
|
||||
() => {
|
||||
selectNextTask();
|
||||
},
|
||||
{
|
||||
scope: Scope.KANBAN,
|
||||
when: !isFullscreen,
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
|
||||
useKeyNavLeft(
|
||||
() => {
|
||||
selectPreviousColumn();
|
||||
},
|
||||
{
|
||||
scope: Scope.KANBAN,
|
||||
when: !isFullscreen,
|
||||
preventDefault: true, // Prevent page scroll
|
||||
}
|
||||
);
|
||||
|
||||
useKeyNavRight(
|
||||
() => {
|
||||
selectNextColumn();
|
||||
},
|
||||
{
|
||||
scope: Scope.KANBAN,
|
||||
when: !isFullscreen,
|
||||
preventDefault: true, // Prevent page scroll
|
||||
}
|
||||
);
|
||||
|
||||
useKeyOpenDetails(() => {}, { scope: Scope.KANBAN });
|
||||
|
||||
// Full screen
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
@@ -188,6 +334,99 @@ export function ProjectTasks() {
|
||||
[projectId, navigateToTask, navigateToAttempt]
|
||||
);
|
||||
|
||||
// Navigation functions that use filtered/grouped tasks
|
||||
const selectNextTask = useCallback(() => {
|
||||
if (selectedTask) {
|
||||
const tasksInStatus = groupedFilteredTasks[selectedTask.status] || [];
|
||||
const currentIndex = tasksInStatus.findIndex(
|
||||
(task) => task.id === selectedTask.id
|
||||
);
|
||||
if (currentIndex >= 0 && currentIndex < tasksInStatus.length - 1) {
|
||||
handleViewTaskDetails(tasksInStatus[currentIndex + 1]);
|
||||
}
|
||||
} else {
|
||||
// Find first non-empty column
|
||||
for (const status of taskStatuses) {
|
||||
const tasks = groupedFilteredTasks[status];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
|
||||
|
||||
const selectPreviousTask = useCallback(() => {
|
||||
if (selectedTask) {
|
||||
const tasksInStatus = groupedFilteredTasks[selectedTask.status] || [];
|
||||
const currentIndex = tasksInStatus.findIndex(
|
||||
(task) => task.id === selectedTask.id
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
handleViewTaskDetails(tasksInStatus[currentIndex - 1]);
|
||||
}
|
||||
} else {
|
||||
// Find first non-empty column
|
||||
for (const status of taskStatuses) {
|
||||
const tasks = groupedFilteredTasks[status];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
|
||||
|
||||
const selectNextColumn = useCallback(() => {
|
||||
if (selectedTask) {
|
||||
const currentIndex = taskStatuses.findIndex(
|
||||
(status) => status === selectedTask.status
|
||||
);
|
||||
// Find next non-empty column
|
||||
for (let i = currentIndex + 1; i < taskStatuses.length; i++) {
|
||||
const tasks = groupedFilteredTasks[taskStatuses[i]];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Find first non-empty column
|
||||
for (const status of taskStatuses) {
|
||||
const tasks = groupedFilteredTasks[status];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
|
||||
|
||||
const selectPreviousColumn = useCallback(() => {
|
||||
if (selectedTask) {
|
||||
const currentIndex = taskStatuses.findIndex(
|
||||
(status) => status === selectedTask.status
|
||||
);
|
||||
// Find previous non-empty column
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const tasks = groupedFilteredTasks[taskStatuses[i]];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Find first non-empty column
|
||||
for (const status of taskStatuses) {
|
||||
const tasks = groupedFilteredTasks[status];
|
||||
if (tasks && tasks.length > 0) {
|
||||
handleViewTaskDetails(tasks[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -214,15 +453,6 @@ export function ProjectTasks() {
|
||||
[tasksById]
|
||||
);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
navigate,
|
||||
currentPath: window.location.pathname,
|
||||
hasOpenDialog: false,
|
||||
closeDialog: () => {},
|
||||
onC: handleCreateNewTask,
|
||||
});
|
||||
|
||||
// Initialize project when projectId changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
@@ -233,7 +463,7 @@ export function ProjectTasks() {
|
||||
// Remove legacy direct-navigation handler; live sync above covers this
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader message="Loading tasks..." size={32} className="py-8" />;
|
||||
return <Loader message={t('loading')} size={32} className="py-8" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -242,7 +472,7 @@ export function ProjectTasks() {
|
||||
<Alert>
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size="16" />
|
||||
Error
|
||||
{t('common:states.error')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
@@ -258,7 +488,7 @@ export function ProjectTasks() {
|
||||
<Alert className="w-full z-30 xl:sticky xl:top-0">
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size="16" />
|
||||
Reconnecting
|
||||
{t('common:states.reconnecting')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{streamError}</AlertDescription>
|
||||
</Alert>
|
||||
@@ -272,27 +502,34 @@ export function ProjectTasks() {
|
||||
<div className="max-w-7xl mx-auto mt-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No tasks found for this project.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t('empty.noTasks')}</p>
|
||||
<Button className="mt-4" onClick={handleCreateNewTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Task
|
||||
{t('empty.createFirst')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="max-w-7xl mx-auto mt-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{t('empty.noSearchResults')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full overflow-x-auto">
|
||||
<TaskKanbanBoard
|
||||
tasks={tasks}
|
||||
searchQuery={searchQuery}
|
||||
groupedTasks={groupedFilteredTasks}
|
||||
onDragEnd={handleDragEnd}
|
||||
onEditTask={handleEditTaskCallback}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onDuplicateTask={handleDuplicateTaskCallback}
|
||||
onViewTaskDetails={handleViewTaskDetails}
|
||||
isPanelOpen={isPanelOpen}
|
||||
selectedTask={selectedTask || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user