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:
Louis Knight-Webb
2025-09-24 12:01:22 +01:00
committed by GitHub
parent 875c5ed792
commit 8891a0beac
44 changed files with 1259 additions and 945 deletions

View File

@@ -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>

View File

@@ -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();
};

View File

@@ -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>

View File

@@ -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');

View File

@@ -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()}>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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('');

View File

@@ -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();
}, []);

View File

@@ -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';

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 : (

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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">

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 &&

View File

@@ -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 };

View File

@@ -59,7 +59,7 @@ export const JSONEditor: React.FC<JSONEditorProps> = ({
autocompletion: true,
bracketMatching: true,
closeBrackets: true,
searchKeymap: true,
searchKeymap: false,
}}
extensions={[
json(),

View File

@@ -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}

View 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;
}

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -7,3 +7,4 @@ export { useCreatePR } from './useCreatePR';
export { useMerge } from './useMerge';
export { usePush } from './usePush';
export { useProjectBranches } from './useProjectBranches';
export { useKeyboardShortcut } from './useKeyboardShortcut';

View 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
);
}

View File

@@ -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,
},
};

View File

@@ -15,7 +15,8 @@
"loading": "Loading...",
"saving": "Saving...",
"error": "Error",
"success": "Success"
"success": "Success",
"reconnecting": "Reconnecting"
},
"language": {
"browserDefault": "Browser Default"

View 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."
}
}

View File

@@ -15,7 +15,8 @@
"loading": "Cargando...",
"saving": "Guardando...",
"error": "Error",
"success": "Éxito"
"success": "Éxito",
"reconnecting": "Reconectando"
},
"language": {
"browserDefault": "Predeterminado del navegador"

View 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."
}
}

View File

@@ -15,7 +15,8 @@
"loading": "読み込み中...",
"saving": "保存中...",
"error": "エラー",
"success": "成功"
"success": "成功",
"reconnecting": "再接続中"
},
"language": {
"browserDefault": "ブラウザ設定"

View File

@@ -0,0 +1,8 @@
{
"loading": "タスクを読み込み中...",
"empty": {
"noTasks": "このプロジェクトにタスクが見つかりません。",
"createFirst": "最初のタスクを作成",
"noSearchResults": "検索条件に一致するタスクがありません。"
}
}

View 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
);

View 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';

View 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))
);
}

View 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}`
);
}
};
}

View File

@@ -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]);
}

View File

@@ -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>
)}