feat: Add keyboard shortcuts for navigation and action buttons

- Created use-keyboard-shortcuts hook to manage global keyboard shortcuts
- Added navigation shortcuts: K (Kanban), A (Agent), E (Spec Editor), C (Context), T (Tools), S (Settings)
- Added action shortcuts: N (Add Feature on board), F (Add File on context)
- Shortcuts automatically disabled when typing in inputs/textareas or when dialogs are open
- Display shortcut key indicators in navigation links and action buttons
- Added test utilities for keyboard shortcut testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-09 02:19:58 -05:00
parent ac73d275af
commit 76d37fc714
6 changed files with 379 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import Link from "next/link";
@@ -30,6 +30,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
useKeyboardShortcuts,
NAV_SHORTCUTS,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
interface NavSection {
label?: string;
@@ -40,6 +45,7 @@ interface NavItem {
id: string;
label: string;
icon: any;
shortcut?: string;
}
export function Sidebar() {
@@ -59,20 +65,52 @@ export function Sidebar() {
{
label: "Project",
items: [
{ id: "board", label: "Kanban Board", icon: LayoutGrid },
{ id: "agent", label: "Agent Runner", icon: Bot },
{ id: "board", label: "Kanban Board", icon: LayoutGrid, shortcut: NAV_SHORTCUTS.board },
{ id: "agent", label: "Agent Runner", icon: Bot, shortcut: NAV_SHORTCUTS.agent },
],
},
{
label: "Tools",
items: [
{ id: "spec", label: "Spec Editor", icon: FileText },
{ id: "context", label: "Context", icon: BookOpen },
{ id: "tools", label: "Agent Tools", icon: Wrench },
{ id: "spec", label: "Spec Editor", icon: FileText, shortcut: NAV_SHORTCUTS.spec },
{ id: "context", label: "Context", icon: BookOpen, shortcut: NAV_SHORTCUTS.context },
{ id: "tools", label: "Agent Tools", icon: Wrench, shortcut: NAV_SHORTCUTS.tools },
],
},
];
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [];
// Only enable nav shortcuts if there's a current project
if (currentProject) {
navSections.forEach((section) => {
section.items.forEach((item) => {
if (item.shortcut) {
shortcuts.push({
key: item.shortcut,
action: () => setCurrentView(item.id as any),
description: `Navigate to ${item.label}`,
});
}
});
});
// Add settings shortcut
shortcuts.push({
key: NAV_SHORTCUTS.settings,
action: () => setCurrentView("settings"),
description: "Navigate to Settings",
});
}
return shortcuts;
}, [currentProject, setCurrentView]);
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
return currentView === id;
};
@@ -250,12 +288,23 @@ export function Sidebar() {
/>
<span
className={cn(
"ml-2.5 font-medium text-sm",
"ml-2.5 font-medium text-sm flex-1",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
{item.label}
</span>
{item.shortcut && sidebarOpen && (
<span
className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500",
isActive && "bg-brand-500/10 border-brand-500/20 text-brand-400"
)}
data-testid={`shortcut-${item.id}`}
>
{item.shortcut}
</span>
)}
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
@@ -304,12 +353,23 @@ export function Sidebar() {
/>
<span
className={cn(
"ml-2.5 font-medium text-sm",
"ml-2.5 font-medium text-sm flex-1",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Settings
</span>
{sidebarOpen && (
<span
className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500",
isActiveRoute("settings") && "bg-brand-500/10 border-brand-500/20 text-brand-400"
)}
data-testid="shortcut-settings"
>
{NAV_SHORTCUTS.settings}
</span>
)}
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
Settings

View File

@@ -47,6 +47,11 @@ import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Use
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
import { useAutoMode } from "@/hooks/use-auto-mode";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
type ColumnId = Feature["status"];
@@ -98,6 +103,19 @@ export function BoardView() {
// Auto mode hook
const autoMode = useAutoMode();
// Keyboard shortcuts for this view
const boardShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: ACTION_SHORTCUTS.addFeature,
action: () => setShowAddDialog(true),
description: "Add new feature",
},
],
[]
);
useKeyboardShortcuts(boardShortcuts);
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
@@ -663,6 +681,12 @@ export function BoardView() {
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
data-testid="shortcut-add-feature"
>
{ACTION_SHORTCUTS.addFeature}
</span>
</Button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
@@ -17,6 +17,11 @@ import {
X,
BookOpen,
} from "lucide-react";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
@@ -49,8 +54,23 @@ export function ContextView() {
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
const [newFileContent, setNewFileContent] = useState("");
const [isDropHovering, setIsDropHovering] = useState(false);
// Get context directory path
// Keyboard shortcuts for this view
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: ACTION_SHORTCUTS.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
},
],
[]
);
useKeyboardShortcuts(contextShortcuts);
// Get context directory path for user-added context files
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
@@ -164,14 +184,16 @@ export function ContextView() {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write empty text file
await api.writeFile(filePath, "");
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
@@ -247,6 +269,49 @@ export function ContextView() {
e.stopPropagation();
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0]; // Only handle the first file
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
console.warn('Only .txt and .md files are supported for drag and drop');
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setNewFileContent(content);
// Auto-fill filename if empty
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsText(file);
};
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(true);
};
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
};
if (!currentProject) {
return (
<div
@@ -303,6 +368,12 @@ export function ContextView() {
>
<Plus className="w-4 h-4 mr-2" />
Add File
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
data-testid="shortcut-add-context-file"
>
{ACTION_SHORTCUTS.addContextFile}
</span>
</Button>
</div>
</div>
@@ -480,6 +551,45 @@ export function ContextView() {
/>
</div>
{newFileType === "text" && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-brand-500"
)}
>
<textarea
id="context-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
onDrop={handleTextAreaDrop}
onDragOver={handleTextAreaDragOver}
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-zinc-900 border border-zinc-700 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent",
isDropHovering && "border-brand-500 bg-brand-500/10"
)}
spellCheck={false}
data-testid="new-file-content"
/>
{isDropHovering && (
<div className="absolute inset-0 flex items-center justify-center bg-brand-500/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-brand-400">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">Drop .txt or .md file here</span>
</div>
</div>
)}
</div>
<p className="text-xs text-zinc-500">
Drag & drop .txt or .md files to import their content
</p>
</div>
)}
{newFileType === "image" && (
<div className="space-y-2">
<Label>Upload Image</Label>
@@ -520,6 +630,8 @@ export function ContextView() {
setIsAddDialogOpen(false);
setNewFileName("");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
}}
>
Cancel

View File

@@ -0,0 +1,109 @@
"use client";
import { useEffect, useCallback } from "react";
export interface KeyboardShortcut {
key: string;
action: () => void;
description?: string;
}
/**
* Check if the currently focused element is an input, textarea, or contenteditable element
* or if an autocomplete/typeahead dropdown is open
*/
function isInputFocused(): boolean {
const activeElement = document.activeElement;
if (!activeElement) return false;
// Check if it's a form input element
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true;
}
// Check if it's a contenteditable element
if (activeElement.getAttribute("contenteditable") === "true") {
return true;
}
// Check if it has a role of textbox or searchbox
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return true;
}
// Check for autocomplete/typeahead dropdowns being open
const autocompleteList = document.querySelector(
'[data-testid="category-autocomplete-list"]'
);
if (autocompleteList) {
return true;
}
// Check for any open dialogs
const dialog = document.querySelector('[role="dialog"][data-state="open"]');
if (dialog) {
return true;
}
return false;
}
/**
* Hook to manage keyboard shortcuts
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (isInputFocused()) {
return;
}
// Don't trigger if any modifier keys are pressed (except for specific combos we want)
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Find matching shortcut
const matchingShortcut = shortcuts.find(
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
);
if (matchingShortcut) {
event.preventDefault();
matchingShortcut.action();
}
},
[shortcuts]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
}
/**
* Shortcut definitions for navigation
*/
export const NAV_SHORTCUTS: Record<string, string> = {
board: "K", // K for Kanban
agent: "A", // A for Agent
spec: "E", // E for Editor (Spec)
context: "C", // C for Context
tools: "T", // T for Tools
settings: "S", // S for Settings
};
/**
* Shortcut definitions for add buttons
*/
export const ACTION_SHORTCUTS: Record<string, string> = {
addFeature: "N", // N for New feature
addContextFile: "F", // F for File (add context file)
};