diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx
index b3bc17ed..5845fcba 100644
--- a/frontend/src/components/tasks/TaskCard.tsx
+++ b/frontend/src/components/tasks/TaskCard.tsx
@@ -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
(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}
>
@@ -100,7 +77,6 @@ export function TaskCard({
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
- onKeyDown={(e) => e.stopPropagation()}
>
diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx
index 1f657abe..795e038f 100644
--- a/frontend/src/components/tasks/TaskDetailsPanel.tsx
+++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx
@@ -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 : (
diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx
index 03833c37..f058edd8 100644
--- a/frontend/src/components/tasks/TaskFollowUpSection.tsx
+++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx
@@ -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}
/>
;
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(
- taskId || null
- );
- const [focusedStatus, setFocusedStatus] = useState(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 = {} as Record;
- 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 (
{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}
/>
))}
diff --git a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx
index 5231e191..7632afa8 100644
--- a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx
+++ b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx
@@ -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);
};
diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
index 19f043d8..20681b53 100644
--- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
+++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
@@ -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);
diff --git a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
index 9808a4e7..966029f1 100644
--- a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
+++ b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
@@ -7,19 +7,22 @@ type Props = {
placeholder: string;
value: string;
onChange: (v: string) => void;
- onKeyDown: (e: React.KeyboardEvent) => void;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
disabled: boolean;
// Loading overlay
showLoadingOverlay: boolean;
+ onCommandEnter?: (e: React.KeyboardEvent) => void;
+ onCommandShiftEnter?: (e: React.KeyboardEvent) => 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 && (
diff --git a/frontend/src/components/ui/auto-expanding-textarea.tsx b/frontend/src/components/ui/auto-expanding-textarea.tsx
index 87ca09f5..85be1ca4 100644
--- a/frontend/src/components/ui/auto-expanding-textarea.tsx
+++ b/frontend/src/components/ui/auto-expanding-textarea.tsx
@@ -3,67 +3,90 @@ import { cn } from '@/lib/utils';
interface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> {
maxRows?: number;
+ onCommandEnter?: (e: React.KeyboardEvent
) => void;
+ onCommandShiftEnter?: (e: React.KeyboardEvent) => void;
}
const AutoExpandingTextarea = React.forwardRef<
HTMLTextAreaElement,
AutoExpandingTextareaProps
->(({ className, maxRows = 10, ...props }, ref) => {
- const internalRef = React.useRef(null);
+>(
+ (
+ { className, maxRows = 10, onCommandEnter, onCommandShiftEnter, ...props },
+ ref
+ ) => {
+ const internalRef = React.useRef(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)
- .current;
- if (!textarea) return;
+ const adjustHeight = React.useCallback(() => {
+ const textarea = (textareaRef as React.RefObject)
+ .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) => {
+ // Adjust height on mount and when content changes
+ React.useEffect(() => {
adjustHeight();
- if (props.onInput) {
- props.onInput(e);
- }
- },
- [adjustHeight, props.onInput]
- );
+ }, [adjustHeight, props.value]);
- return (
-
- );
-});
+ // Handle keyboard shortcuts
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ 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) => {
+ adjustHeight();
+ if (props.onInput) {
+ props.onInput(e);
+ }
+ },
+ [adjustHeight, props.onInput]
+ );
+
+ return (
+
+ );
+ }
+);
AutoExpandingTextarea.displayName = 'AutoExpandingTextarea';
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
index be82d104..1d5c1ec6 100644
--- a/frontend/src/components/ui/dialog.tsx
+++ b/frontend/src/components/ui/dialog.tsx
@@ -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;
diff --git a/frontend/src/components/ui/file-search-textarea.tsx b/frontend/src/components/ui/file-search-textarea.tsx
index d84e6108..eb3014a8 100644
--- a/frontend/src/components/ui/file-search-textarea.tsx
+++ b/frontend/src/components/ui/file-search-textarea.tsx
@@ -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) => void;
+ onCommandShiftEnter?: (e: React.KeyboardEvent) => 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) => {
- // 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) => {
+ // 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 (
{showDropdown &&
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
index 2ff20bab..342f39da 100644
--- a/frontend/src/components/ui/input.tsx
+++ b/frontend/src/components/ui/input.tsx
@@ -1,25 +1,52 @@
import * as React from 'react';
-
import { cn } from '@/lib/utils';
export interface InputProps
- extends React.InputHTMLAttributes {}
+ extends React.InputHTMLAttributes {
+ onCommandEnter?: (e: React.KeyboardEvent) => void;
+ onCommandShiftEnter?: (e: React.KeyboardEvent) => void;
+}
const Input = React.forwardRef(
- ({ className, type, ...props }, ref) => {
+ (
+ {
+ className,
+ type,
+ onKeyDown,
+ onCommandEnter,
+ onCommandShiftEnter,
+ ...props
+ },
+ ref
+ ) => {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ 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.displayName = 'Input';
+Input.displayName = 'Input';
export { Input };
diff --git a/frontend/src/components/ui/json-editor.tsx b/frontend/src/components/ui/json-editor.tsx
index 7e322126..49073f6e 100644
--- a/frontend/src/components/ui/json-editor.tsx
+++ b/frontend/src/components/ui/json-editor.tsx
@@ -59,7 +59,7 @@ export const JSONEditor: React.FC = ({
autocompletion: true,
bracketMatching: true,
closeBrackets: true,
- searchKeymap: true,
+ searchKeymap: false,
}}
extensions={[
json(),
diff --git a/frontend/src/components/ui/shadcn-io/kanban/index.tsx b/frontend/src/components/ui/shadcn-io/kanban/index.tsx
index 22c8eb09..663603f2 100644
--- a/frontend/src/components/ui/shadcn-io/kanban/index.tsx
+++ b/frontend/src/components/ui/shadcn-io/kanban/index.tsx
@@ -63,6 +63,7 @@ export type KanbanCardProps = Pick & {
tabIndex?: number;
forwardedRef?: Ref;
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 (
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(
+ null
+);
+
+interface KeyboardShortcutsProviderProps {
+ children: ReactNode;
+}
+
+export function KeyboardShortcutsProvider({
+ children,
+}: KeyboardShortcutsProviderProps) {
+ const [shortcuts, setShortcuts] = useState([]);
+ const idCounter = useRef(0);
+ const shortcutsRef = useRef([]);
+
+ // 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 (
+
+ {children}
+
+ );
+}
+
+export function useKeyboardShortcutsRegistry(): KeyboardShortcutsState {
+ const context = useContext(KeyboardShortcutsContext);
+ if (!context) {
+ throw new Error(
+ 'useKeyboardShortcutsRegistry must be used within a KeyboardShortcutsProvider'
+ );
+ }
+ return context;
+}
diff --git a/frontend/src/contexts/search-context.tsx b/frontend/src/contexts/search-context.tsx
index 664e307f..a2ad5b72 100644
--- a/frontend/src/contexts/search-context.tsx
+++ b/frontend/src/contexts/search-context.tsx
@@ -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(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(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 (
diff --git a/frontend/src/hooks/follow-up/useDefaultVariant.ts b/frontend/src/hooks/follow-up/useDefaultVariant.ts
index 65883acb..8336011e 100644
--- a/frontend/src/hooks/follow-up/useDefaultVariant.ts
+++ b/frontend/src/hooks/follow-up/useDefaultVariant.ts
@@ -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;
}
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index 4195506d..3db6ebd5 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -7,3 +7,4 @@ export { useCreatePR } from './useCreatePR';
export { useMerge } from './useMerge';
export { usePush } from './usePush';
export { useProjectBranches } from './useProjectBranches';
+export { useKeyboardShortcut } from './useKeyboardShortcut';
diff --git a/frontend/src/hooks/useKeyboardShortcut.ts b/frontend/src/hooks/useKeyboardShortcut.ts
new file mode 100644
index 00000000..a79da385
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcut.ts
@@ -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
+ );
+}
diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts
index 1c4a50dc..5312726d 100644
--- a/frontend/src/i18n/config.ts
+++ b/frontend/src/i18n/config.ts
@@ -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,
},
};
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index ba2bb03c..7bca7750 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -15,7 +15,8 @@
"loading": "Loading...",
"saving": "Saving...",
"error": "Error",
- "success": "Success"
+ "success": "Success",
+ "reconnecting": "Reconnecting"
},
"language": {
"browserDefault": "Browser Default"
diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json
new file mode 100644
index 00000000..e2f01e41
--- /dev/null
+++ b/frontend/src/i18n/locales/en/tasks.json
@@ -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."
+ }
+}
diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json
index 64917f07..8846659e 100644
--- a/frontend/src/i18n/locales/es/common.json
+++ b/frontend/src/i18n/locales/es/common.json
@@ -15,7 +15,8 @@
"loading": "Cargando...",
"saving": "Guardando...",
"error": "Error",
- "success": "Éxito"
+ "success": "Éxito",
+ "reconnecting": "Reconectando"
},
"language": {
"browserDefault": "Predeterminado del navegador"
diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json
new file mode 100644
index 00000000..4286cb13
--- /dev/null
+++ b/frontend/src/i18n/locales/es/tasks.json
@@ -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."
+ }
+}
diff --git a/frontend/src/i18n/locales/ja/common.json b/frontend/src/i18n/locales/ja/common.json
index bc53b854..1003ccf8 100644
--- a/frontend/src/i18n/locales/ja/common.json
+++ b/frontend/src/i18n/locales/ja/common.json
@@ -15,7 +15,8 @@
"loading": "読み込み中...",
"saving": "保存中...",
"error": "エラー",
- "success": "成功"
+ "success": "成功",
+ "reconnecting": "再接続中"
},
"language": {
"browserDefault": "ブラウザ設定"
diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json
new file mode 100644
index 00000000..1fcb3576
--- /dev/null
+++ b/frontend/src/i18n/locales/ja/tasks.json
@@ -0,0 +1,8 @@
+{
+ "loading": "タスクを読み込み中...",
+ "empty": {
+ "noTasks": "このプロジェクトにタスクが見つかりません。",
+ "createFirst": "最初のタスクを作成",
+ "noSearchResults": "検索条件に一致するタスクがありません。"
+ }
+}
diff --git a/frontend/src/keyboard/hooks.ts b/frontend/src/keyboard/hooks.ts
new file mode 100644
index 00000000..e6d7385a
--- /dev/null
+++ b/frontend/src/keyboard/hooks.ts
@@ -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
+);
diff --git a/frontend/src/keyboard/index.ts b/frontend/src/keyboard/index.ts
new file mode 100644
index 00000000..e8ed89b1
--- /dev/null
+++ b/frontend/src/keyboard/index.ts
@@ -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';
diff --git a/frontend/src/keyboard/registry.ts b/frontend/src/keyboard/registry.ts
new file mode 100644
index 00000000..e34f0d18
--- /dev/null
+++ b/frontend/src/keyboard/registry.ts
@@ -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))
+ );
+}
diff --git a/frontend/src/keyboard/useSemanticKey.ts b/frontend/src/keyboard/useSemanticKey.ts
new file mode 100644
index 00000000..2e5ca0e6
--- /dev/null
+++ b/frontend/src/keyboard/useSemanticKey.ts
@@ -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(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}`
+ );
+ }
+ };
+}
diff --git a/frontend/src/lib/keyboard-shortcuts.ts b/frontend/src/lib/keyboard-shortcuts.ts
deleted file mode 100644
index 1f40c8a6..00000000
--- a/frontend/src/lib/keyboard-shortcuts.ts
+++ /dev/null
@@ -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;
- closeDialog?: () => void;
- onC?: () => void;
- currentPath?: string;
- hasOpenDialog?: boolean;
- location?: ReturnType;
- stopExecution?: () => void;
- newAttempt?: () => void;
- onEnter?: () => void;
- ignoreEscape?: boolean;
-}
-
-// Centralized shortcut definitions
-export const createKeyboardShortcuts = (
- context: KeyboardShortcutContext
-): Record => ({
- 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;
- 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]);
-}
diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx
index 3c9ccd1a..b6f24ab5 100644
--- a/frontend/src/pages/project-tasks.tsx
+++ b/frontend/src/pages/project-tasks.tsx
@@ -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(null);
const [error, setError] = useState(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(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 = {};
+ 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 ;
+ return ;
}
if (error) {
@@ -242,7 +472,7 @@ export function ProjectTasks() {
- Error
+ {t('common:states.error')}
{error}
@@ -258,7 +488,7 @@ export function ProjectTasks() {
- Reconnecting
+ {t('common:states.reconnecting')}
{streamError}
@@ -272,27 +502,34 @@ export function ProjectTasks() {
-
- No tasks found for this project.
-
+ {t('empty.noTasks')}
+ ) : filteredTasks.length === 0 ? (
+
+
+
+
+ {t('empty.noSearchResults')}
+
+
+
+
) : (
)}